diff --git a/README.md b/README.md index 8f2320dbf3..cb2f41130f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [h Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`** +**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) >= `v1.x`** ```bash # clone the repo @@ -65,7 +65,7 @@ Requirements ------------ - [Node.js](https://nodejs.org), [npm](https://www.npmjs.com/), and [yarn](https://yarnpkg.com) -- Ensure you're running node >= `v5.x`, npm >= `v3.x` and yarn >= `v0.20.x` +- Ensure you're running node >= `v8.x`, npm >= `v5.x` and yarn >= `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. diff --git a/config/environment.default.js b/config/environment.default.js index 3c1144fc6f..804d80b0f2 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -10,7 +10,7 @@ module.exports = { // The REST API server settings. rest: { ssl: true, - host: 'dspace7.4science.it', + host: 'dspace7.4science.cloud', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: '/dspace-spring-rest/api' @@ -48,6 +48,68 @@ module.exports = { // NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' animate: 'scale' }, + // Submission settings + submission: { + autosave: { + // NOTE: which metadata trigger an autosave + metadata: ['dc.title', 'dc.identifier.doi', 'dc.identifier.pmid', 'dc.identifier.arxiv'], + // NOTE: every how many minutes submission is saved automatically + timer: 5 + }, + icons: { + metadata: [ + /** + * NOTE: example of configuration + * { + * // NOTE: metadata name + * name: 'dc.author', + * // NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + * style: 'fa-user' + * } + */ + { + name: 'dc.author', + style: 'fas fa-user' + }, + // default configuration + { + name: 'default', + style: '' + } + ], + authority: { + confidence: [ + /** + * NOTE: example of configuration + * { + * // NOTE: confidence value + * value: 'dc.author', + * // NOTE: fontawesome (v4.x) icon classes and bootstrap utility classes can be used + * style: 'fa-user' + * } + */ + { + value: 600, + style: 'text-success' + }, + { + value: 500, + style: 'text-info' + }, + { + value: 400, + style: 'text-warning' + }, + // default configuration + { + value: 'default', + style: 'text-muted' + }, + + ] + } + } + }, // Angular Universal settings universal: { preboot: true, @@ -59,5 +121,39 @@ module.exports = { // Log directory logDirectory: '.', // NOTE: will log all redux actions and transfers in console - debug: false + debug: false, + // Default Language in which the UI will be rendered if the user's browser language is not an active language + defaultLanguage: 'en', + // Languages. DSpace Angular holds a message catalog for each of the following languages. When set to active, users will be able to switch to the use of this language in the user interface. + languages: [{ + code: 'en', + label: 'English', + active: true, + }, { + code: 'de', + label: 'Deutsch', + active: true, + }, { + code: 'cs', + label: 'Čeština', + active: true, + }, { + code: 'nl', + label: 'Nederlands', + active: false, + }], + // Browse-By Pages + browseBy: { + // Amount of years to display using jumps of one year (current year - oneYearLimit) + oneYearLimit: 10, + // Limit for years to display using jumps of five years (current year - fiveYearLimit) + fiveYearLimit: 30, + // The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) + defaultLowerLimit: 1900 + }, + item: { + edit: { + undoTimeout: 10000 // 10 seconds + } + } }; diff --git a/e2e/app.e2e-spec.ts b/e2e/app.e2e-spec.ts index f5ac9094d0..6856a6f01b 100644 --- a/e2e/app.e2e-spec.ts +++ b/e2e/app.e2e-spec.ts @@ -12,8 +12,8 @@ describe('protractor App', () => { expect(page.getPageTitleText()).toEqual('DSpace Angular :: Home'); }); - it('should display header "Welcome to DSpace"', () => { + it('should contain a news section', () => { page.navigateTo(); - expect(page.getFirstHeaderText()).toEqual('Welcome to DSpace'); + expect(page.getHomePageNewsText()).toBeDefined(); }); }); diff --git a/e2e/app.po.ts b/e2e/app.po.ts index d8d2acf120..54b5b55af3 100644 --- a/e2e/app.po.ts +++ b/e2e/app.po.ts @@ -9,11 +9,7 @@ export class ProtractorPage { return browser.getTitle(); } - getFirstPText() { - return element(by.xpath('//p[1]')).getText(); - } - - getFirstHeaderText() { - return element(by.xpath('//h1[1]')).getText(); + getHomePageNewsText() { + return element(by.xpath('//ds-home-news')).getText(); } } diff --git a/package.json b/package.json index fd037de3f7..cc687ea269 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "prebuild": "yarn run clean:dist", "prebuild:aot": "yarn run prebuild", "prebuild:prod": "yarn run prebuild", - "build": "webpack --progress --mode development", - "build:aot": "webpack --env.aot --env.server --mode development && webpack --env.aot --env.client --mode development", - "build:prod": "webpack --env.aot --env.server --mode production && webpack --env.aot --env.client --mode production", + "build": "node ./webpack/run-webpack.js --progress --mode development", + "build:aot": "node ./webpack/run-webpack.js --env.aot --env.server --mode development && node ./webpack/run-webpack.js --env.aot --env.client --mode development", + "build:prod": "node ./webpack/run-webpack.js --env.aot --env.server --mode production && node ./webpack/run-webpack.js --env.aot --env.client --mode production", "postbuild:prod": "yarn run rollup", "rollup": "rollup -c rollup.config.js", "prestart": "yarn run build:prod", @@ -40,7 +40,7 @@ "server": "node dist/server.js", "server:watch": "nodemon dist/server.js", "server:watch:debug": "nodemon --debug dist/server.js", - "webpack:watch": "webpack -w --mode development", + "webpack:watch": "node ./webpack/run-webpack.js -w --mode development", "watch": "yarn run build && npm-run-all -p webpack:watch server:watch", "watch:debug": "yarn run build && npm-run-all -p webpack:watch server:watch:debug", "predebug": "yarn run build", @@ -75,8 +75,8 @@ "@angular/router": "^6.1.4", "@angularclass/bootloader": "1.0.1", "@ng-bootstrap/ng-bootstrap": "^2.0.0", - "@ng-dynamic-forms/core": "6.0.9", - "@ng-dynamic-forms/ui-ng-bootstrap": "6.0.9", + "@ng-dynamic-forms/core": "6.2.0", + "@ng-dynamic-forms/ui-ng-bootstrap": "6.2.0", "@ngrx/effects": "^6.1.0", "@ngrx/router-store": "^6.1.0", "@ngrx/store": "^6.1.0", @@ -89,7 +89,7 @@ "angular2-text-mask": "9.0.0", "angulartics2": "^6.2.0", "body-parser": "1.18.2", - "bootstrap": "4.1.3", + "bootstrap": "4.3.1", "cerialize": "0.1.18", "compression": "1.7.1", "cookie-parser": "1.4.3", @@ -97,6 +97,7 @@ "express": "4.16.2", "express-session": "1.15.6", "fast-json-patch": "^2.0.7", + "file-saver": "^1.3.8", "font-awesome": "4.7.0", "fork-ts-checker-webpack-plugin": "^0.4.10", "http-server": "0.11.1", @@ -107,11 +108,11 @@ "jwt-decode": "^2.2.0", "methods": "1.1.2", "moment": "^2.22.1", - "morgan": "1.9.0", + "morgan": "^1.9.1", "ng-mocks": "^6.2.1", "ng2-file-upload": "1.2.1", "ng2-nouislider": "^1.7.11", - "ngx-bootstrap": "^3.0.1", + "ngx-bootstrap": "^3.2.0", "ngx-infinite-scroll": "6.0.1", "ngx-moment": "^3.1.0", "ngx-pagination": "3.0.3", @@ -119,6 +120,7 @@ "pem": "1.12.3", "reflect-metadata": "0.1.12", "rxjs": "6.2.2", + "rxjs-spy": "^7.5.1", "sortablejs": "1.7.0", "text-mask-core": "5.0.1", "ts-loader": "^5.2.1", @@ -142,6 +144,7 @@ "@types/deep-freeze": "0.1.1", "@types/express": "^4.11.1", "@types/express-serve-static-core": "4.16.0", + "@types/file-saver": "^1.3.0", "@types/hammerjs": "2.0.35", "@types/jasmine": "^2.8.6", "@types/js-cookie": "2.1.0", @@ -162,6 +165,7 @@ "copy-webpack-plugin": "^4.4.1", "coveralls": "3.0.0", "css-loader": "1.0.0", + "cssnano": "^4.1.10", "deep-freeze": "0.0.1", "exports-loader": "^0.7.0", "html-webpack-plugin": "^4.0.0-alpha", @@ -187,6 +191,7 @@ "node-sass": "^4.11.0", "nodemon": "^1.15.0", "npm-run-all": "4.1.3", + "optimize-css-assets-webpack-plugin": "^5.0.1", "postcss": "^7.0.2", "postcss-apply": "0.11.0", "postcss-cli": "^6.0.0", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 05ddf5d378..9953c76463 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -14,6 +14,38 @@ } } }, + "form": { + "title": "Name", + "description": "Introductory text (HTML)", + "abstract": "Short Description", + "rights": "Copyright text (HTML)", + "tableofcontents": "News (HTML)", + "license": "License", + "provenance": "Provenance", + "errors": { + "title": { + "required": "Please enter a collection name" + } + } + }, + "edit": { + "head": "Edit Collection", + "delete": "Delete this collection" + }, + "create": { + "head": "Create a Collection", + "sub-head": "Create a Collection for Community {{ parent }}" + }, + "delete": { + "head": "Delete Collection", + "text": "Are you sure you want to delete collection \"{{ dso }}\"", + "confirm": "Confirm", + "cancel": "Cancel", + "notification": { + "success": "Successfully deleted collection", + "fail": "Collection could not be deleted" + } + }, "item-mapper": { "head": "Item Mapper - Map Items from Other Collections", "collection": "Collection: \"{{name}}\"", @@ -65,16 +97,48 @@ }, "sub-community-list": { "head": "Communities of this Community" + }, + "form": { + "title": "Name", + "description": "Introductory text (HTML)", + "abstract": "Short Description", + "rights": "Copyright text (HTML)", + "tableofcontents": "News (HTML)", + "errors": { + "title": { + "required": "Please enter a community name" + } + } + }, + "edit": { + "head": "Edit Community", + "delete": "Delete this community" + }, + "create": { + "head": "Create a Community", + "sub-head": "Create a Sub-Community for Community {{ parent }}" + }, + "delete": { + "head": "Delete Community", + "text": "Are you sure you want to delete community \"{{ dso }}\"", + "confirm": "Confirm", + "cancel": "Cancel", + "notification": { + "success": "Successfully deleted community", + "fail": "Community could not be deleted" + } } }, "item": { "page": { - "author": "Author", + "author": "Authors", "abstract": "Abstract", "date": "Date", "uri": "URI", "files": "Files", "collections": "Collections", + "subject": "Keywords", + "citation": "Citation", "filesection": { "download": "Download", "name": "Name:", @@ -101,6 +165,7 @@ "status": { "head": "Item Status", "description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.", + "title": "Item Edit - Status", "labels": { "id": "Item Internal ID", "handle": "Handle", @@ -143,16 +208,20 @@ } }, "bitstreams": { - "head": "Item Bitstreams" + "head": "Item Bitstreams", + "title": "Item Edit - Bitstreams" }, "metadata": { - "head": "Item Metadata" + "head": "Item Metadata", + "title": "Item Edit - Metadata" }, "view": { - "head": "View Item" + "head": "View Item", + "title": "Item Edit - View" }, "curate": { - "head": "Curate" + "head": "Curate", + "title": "Item Edit - Curate" } }, "modify.overview": { @@ -166,7 +235,7 @@ "confirm": "Withdraw", "cancel": "Cancel", "success": "The item was withdrawn successfully", - "error": "An error occured while withdrawing the item" + "error": "An error occurred while withdrawing the item" }, "reinstate": { "header": "Reinstate item: {{ id }}", @@ -174,7 +243,7 @@ "confirm": "Reinstate", "cancel": "Cancel", "success": "The item was reinstated successfully", - "error": "An error occured while reinstating the item" + "error": "An error occurred while reinstating the item" }, "private": { "header": "Make item private: {{ id }}", @@ -182,7 +251,7 @@ "confirm": "Make it Private", "cancel": "Cancel", "success": "The item is now private", - "error": "An error occured while making the item private" + "error": "An error occurred while making the item private" }, "public": { "header": "Make item public: {{ id }}", @@ -190,7 +259,7 @@ "confirm": "Make it Public", "cancel": "Cancel", "success": "The item is now public", - "error": "An error occured while making the item public" + "error": "An error occurred while making the item public" }, "delete": { "header": "Delete item: {{ id }}", @@ -198,7 +267,48 @@ "confirm": "Delete", "cancel": "Cancel", "success": "The item has been deleted", - "error": "An error occured while deleting the item" + "error": "An error occurred while deleting the item" + }, + "metadata": { + "add-button": "Add", + "discard-button": "Discard", + "reinstate-button": "Undo", + "save-button": "Save", + "headers": { + "field": "Field", + "value": "Value", + "language": "Lang", + "edit": "Edit" + }, + "edit": { + "buttons": { + "edit": "Edit", + "unedit": "Stop editing", + "remove": "Remove", + "undo": "Undo changes" + } + }, + "metadatafield": { + "invalid": "Please choose a valid metadata field" + }, + "notifications": { + "outdated": { + "title": "Changed outdated", + "content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts" + }, + "discarded": { + "title": "Changed discarded", + "content": "Your changes were discarded. To reinstate your changes click the 'Undo' button" + }, + "invalid": { + "title": "Metadata invalid", + "content": "Your changes were not saved. Please make sure all fields are valid before you save." + }, + "saved": { + "title": "Metadata saved", + "content": "Your changes to this item's metadata were saved." + } + } }, "item-mapper": { "head": "Item Mapper - Map Item to Collections", @@ -238,6 +348,114 @@ } } }, + "relationships": { + "isPublicationOf": "Publications", + "isProjectOf": "Research Projects", + "isOrgUnitOf": "Organizational Units", + "isAuthorOf": "Authors", + "isPersonOf": "Authors", + "isJournalOf": "Journals", + "isSingleJournalOf": "Journal", + "isVolumeOf": "Journal Volumes", + "isSingleVolumeOf": "Journal Volume", + "isIssueOf": "Journal Issues", + "isJournalIssueOf": "Journal Issue", + "isPublicationOfJournalIssue": "Articles" + }, + "person": { + "page": { + "titleprefix": "Person: ", + "jobtitle": "Job Title", + "lastname": "Last Name", + "firstname": "First Name", + "email": "Email Address", + "orcid": "ORCID", + "birthdate": "Birth Date", + "staffid": "Staff ID", + "link": { + "full": "Show all metadata" + } + }, + "listelement": { + "badge": "Person" + } + }, + "project": { + "page": { + "titleprefix": "Research Project: ", + "status": "Status", + "contributor": "Contributors", + "funder": "Funders", + "id": "ID", + "expectedcompletion": "Expected Completion", + "description": "Description", + "keyword": "Keywords" + }, + "listelement": { + "badge": "Research Project" + } + }, + "orgunit": { + "page": { + "titleprefix": "Organizational Unit: ", + "dateestablished": "Date established", + "city": "City", + "country": "Country", + "id": "ID", + "description": "Description" + }, + "listelement": { + "badge": "Organizational Unit" + } + }, + "journal": { + "page": { + "titleprefix": "Journal: ", + "issn": "ISSN", + "publisher": "Publisher", + "description": "Description", + "editor": "Editor-in-Chief" + }, + "listelement": { + "badge": "Journal" + } + }, + "journalvolume": { + "page": { + "titleprefix": "Journal Volume: ", + "volume": "Volume", + "issuedate": "Issue Date", + "description": "Description" + }, + "listelement": { + "badge": "Journal Volume" + } + }, + "journalissue": { + "page": { + "titleprefix": "Journal Issue: ", + "number": "Number", + "issuedate": "Issue Date", + "description": "Description", + "keyword": "Keywords", + "journal-title": "Journal Title", + "journal-issn": "Journal ISSN" + }, + "listelement": { + "badge": "Journal Issue" + } + }, + "publication": { + "page": { + "titleprefix": "Publication: ", + "journal-title": "Journal Title", + "journal-issn": "Journal ISSN", + "volume-title": "Volume Title" + }, + "listelement": { + "badge": "Publication" + } + }, "nav": { "browse": { "header": "All of DSpace" @@ -250,6 +468,7 @@ }, "login": "Log In", "logout": "Log Out", + "mydspace": "MyDSpace", "language": "Language switch", "search": "Search" }, @@ -257,7 +476,7 @@ "results-per-page": "Results Per Page", "sort-direction": "Sort Options", "showing": { - "label": "Now showing items ", + "label": "Now showing ", "detail": "{{ range }} of {{ total }}" } }, @@ -286,12 +505,82 @@ "help": "Select a community to browse its collections." } }, + "mydspace": { + "title": "MyDSpace", + "description": "", + "new-submission": "New submission", + "results": { + "head": "Your submissions", + "no-results": "There were no items to show", + "no-title": "No title", + "no-authors": "No Authors", + "no-date": "No Date", + "no-abstract": "No Abstract", + "no-files": "No Files", + "no-uri": "No Uri", + "no-collections": "No Collections" + }, + "messages": { + "title": "Messages", + "to": "To", + "hide-msg": "Hide message", + "show-msg": "Show message", + "no-messages": "No messages yet.", + "no-content": "No content.", + "send-btn": "Send", + "subject-placeholder": "Subject...", + "description-placeholder": "Insert your message here...", + "mark-as-read": "Mark as read", + "mark-as-unread": "Mark as unread", + "submitter-help": "Select this option to send a message to controller.", + "controller-help": "Select this option to send a message to item's submitter." + }, + "show": { + "workspace": "Your Submissions", + "workflow": "All tasks" + }, + "status": { + "workflow": "Workflow", + "validation": "Validation", + "waiting-for-controller": "Waiting for controller", + "workspace": "Workspace", + "archived": "Archived" + }, + "view-btn": "View", + "general": { + "text-here": "HERE" + }, + "upload": { + "upload-successful": "New workspace item created. Click {{here}} for edit it.", + "upload-multiple-successful": "{{qty}} new workspace items created.", + "upload-failed": "Error creating new workspace. Please verify the content uploaded before retry." + } + }, "search": { + "journal": { + "title": "DSpace Angular :: Journal Search", + "results": { + "head": "Journal Search Results" + } + }, + "person": { + "title": "DSpace Angular :: Person Search", + "results": { + "head": "Person Search Results" + } + }, + "publication": { + "title": "DSpace Angular :: Publication Search", + "results": { + "head": "Publication Search Results" + } + }, "title": "DSpace Angular :: Search", "description": "", "form": { "search": "Search", - "search_dspace": "Search DSpace" + "search_dspace": "Search DSpace", + "search_mydspace": "Search MyDSpace" }, "results": { "head": "Search Results", @@ -311,9 +600,13 @@ "rpp": "Results per page" } }, + "switch-configuration": { + "title":"Show" + }, "view-switch": { "show-list": "Show as list", - "show-grid": "Show as grid" + "show-grid": "Show as grid", + "show-detail": "Show detail" }, "filters": { "head": "Filters", @@ -323,7 +616,12 @@ "f.dateIssued.min": "Start date", "f.dateIssued.max": "End date", "f.subject": "Subject", - "f.has_content_in_original_bundle": "Has files" + "f.has_content_in_original_bundle": "Has files", + "f.entityType": "Item Type", + "f.namedresourcetype": "Status", + "f.dateSubmitted": "Date submitted", + "f.itemtype": "Type", + "f.submitter": "Submitter" }, "filter": { "show-more": "Show more", @@ -351,12 +649,75 @@ }, "has_content_in_original_bundle": { "head": "Has files" + }, + "entityType": { + "placeholder": "Item Type", + "head": "Item Type" + }, + "namedresourcetype": { + "placeholder": "Status", + "head": "Status" + }, + "dateSubmitted": { + "placeholder": "Date submitted", + "head": "Date submitted" + }, + "itemtype": { + "placeholder": "Type", + "head": "Type" + }, + "submitter": { + "placeholder": "Submitter", + "head": "Submitter" + }, + "objectpeople": { + "placeholder": "People", + "head": "People" } } } }, "browse": { - "title": "Browsing {{ collection }} by {{ field }} {{ value }}" + "title": "Browsing {{ collection }} by {{ field }} {{ value }}", + "startsWith": { + "jump": "Jump to a point in the index:", + "choose_year": "(Choose year)", + "choose_start": "(Choose start)", + "type_date": "Or type in a date (year-month):", + "type_text": "Or enter first few letters:", + "months": { + "none": "(Choose month)", + "january": "January", + "february": "February", + "march": "March", + "april": "April", + "may": "May", + "june": "June", + "july": "July", + "august": "August", + "september": "September", + "october": "October", + "november": "November", + "december": "December" + }, + "submit": "Go" + }, + "metadata": { + "title": "Title", + "author": "Author", + "subject": "Subject", + "dateissued": "Issue Date" + }, + "comcol": { + "head": "Browse", + "by": { + "title": "By Title", + "dateissued": "By Issue Date", + "author": "By Author", + "subject": "By Subject" + } + }, + "empty": "No items to show." }, "admin": { "registries": { @@ -364,11 +725,18 @@ "title": "DSpace Angular :: Metadata Registry", "head": "Metadata Registry", "description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.", + "form": { + "create": "Create metadata schema", + "edit": "Edit metadata schema", + "namespace": "Namespace", + "name": "Name" + }, "schemas": { "table": { "id": "ID", "namespace": "Namespace", - "name": "Name" + "name": "Name", + "delete": "Delete selected" }, "no-items": "No metadata schemas to show." } @@ -377,13 +745,40 @@ "title": "DSpace Angular :: Metadata Schema Registry", "head": "Metadata Schema", "description": "This is the metadata schema for \"{{namespace}}\".", + "return": "Return", + "form": { + "create": "Create metadata field", + "edit": "Edit metadata field", + "element": "Element", + "qualifier": "Qualifier", + "scopenote": "Scope Note" + }, "fields": { "head": "Schema metadata fields", "table": { "field": "Field", - "scopenote": "Scope Note" + "scopenote": "Scope Note", + "delete": "Delete selected" }, "no-items": "No metadata fields to show." + }, + "notification": { + "success": "Success", + "failure": "Error", + "created": "Successfully created metadata schema \"{{prefix}}\"", + "edited": "Successfully edited metadata schema \"{{prefix}}\"", + "deleted": { + "success": "Successfully deleted {{amount}} metadata schemas", + "failure": "Failed to delete {{amount}} metadata schemas" + }, + "field": { + "created": "Successfully created metadata field \"{{field}}\"", + "edited": "Successfully edited metadata field \"{{field}}\"", + "deleted": { + "success": "Successfully deleted {{amount}} metadata fields", + "failure": "Failed to delete {{amount}} metadata fields" + } + } } }, "bitstream-formats": { @@ -453,6 +848,7 @@ "browse_global_by_issue_date": "By Issue Date", "browse_global_by_author": "By Author", "browse_global_by_title": "By Title", + "browse_global_by_subject": "By Subject", "statistics": "Statistics", "browse_community": "This Community", "browse_community_by_issue_date": "By Issue Date", @@ -497,7 +893,9 @@ "item": "Loading item...", "objects": "Loading...", "search-results": "Loading search results...", - "browse-by": "Loading items..." + "mydspace-results": "Loading items...", + "browse-by": "Loading items...", + "browse-by-page": "Loading page..." }, "error": { "default": "Error", @@ -516,13 +914,25 @@ "license": { "notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission." } + }, + "submission": { + "sections": { + "init-form-error": "An error occurred during section initialize, please check your input-form configuration. Details are below :

" + } } }, "form": { "submit": "Submit", "cancel": "Cancel", "search": "Search", + "search-help": "Click here to looking for an existing correspondence", "remove": "Remove", + "clear": "Clear", + "clear-help": "Click here to remove the selected value", + "edit": "Edit", + "edit-help": "Click here to edit the selected value", + "save": "Save", + "save-help": "Save changes", "first-name": "First name", "last-name": "Last name", "loading": "Loading...", @@ -531,7 +941,9 @@ "group-collapse": "Collapse", "group-expand": "Expand", "group-collapse-help": "Click here to collapse", - "group-expand-help": "Click here to expand and add more elements" + "group-expand-help": "Click here to expand and add more elements", + "other-information": { + } }, "login": { "title": "Login", @@ -561,5 +973,168 @@ }, "chips": { "remove": "Remove chip" + }, + "dso-selector": { + "create": { + "community": { + "head": "New community", + "sub-level": "Create a new community in", + "top-level": "Create a new top-level community" + }, + "collection": { + "head": "New collection" + }, + "item": { + "head": "New item" + } + }, + "edit": { + "community": { + "head": "Edit community" + }, + "collection": { + "head": "Edit collection" + }, + "item": { + "head": "Edit item" + } + }, + "placeholder": "Search for a {{ type }}", + "no-results": "No {{ type }} found" + }, + "submission": { + "general":{ + "cannot_submit": "You have not the privilege to make a new submission.", + "deposit": "Deposit", + "discard": { + "submit": "Discard", + "confirm": { + "cancel": "Cancel", + "submit": "Yes, I'm sure", + "title": "Discard submission", + "info": "This operation can't be undone. Are you sure?" + } + }, + "save": "Save", + "save-later": "Save for later" + }, + "submit": { + "title": "Submission" + }, + "edit": { + "title": "Edit Submission" + }, + "mydspace": { + + }, + "sections": { + + "general": { + "add-more": "Add more", + "no-sections": "No options available", + "sections_not_valid": "There are incomplete sections.", + "collection": "Collection", + "no-collection": "No collection found", + "search-collection": "Search for a collection", + "save_error_notice": "There was an issue when saving the item, please try again later.", + "deposit_success_notice": "Submission deposited successfully.", + "deposit_error_notice": "There was an issue when submitting the item, please try again later.", + "discard_success_notice": "Submission discarded successfully.", + "discard_error_notice": "There was an issue when discarding the item, please try again later.", + "save_success_notice": "Submission saved successfully.", + "metadata-extracted": "New metadata have been extracted and added to the {{sectionId}} section.", + "metadata-extracted-new-section": "New {{sectionId}} section has been added to submission." + }, + "submit.progressbar.describe.stepone": "Describe", + "submit.progressbar.describe.steptwo": "Describe", + "submit.progressbar.describe.stepcustom": "Describe", + "submit.progressbar.describe.recycle": "Recycle", + "submit.progressbar.upload": "Upload files", + "submit.progressbar.license": "Deposit license", + "submit.progressbar.cclicense": "Creative commons license", + "submit.progressbar.detect-duplicate": "Potential duplicates", + + "upload": { + "no-entry": "No", + "no-file-uploaded": "No file uploaded yet.", + "info": "Here you will find all the files currently in the item. You can update the fle metadata and access conditions or upload additional files just dragging & dropping them everywhere in the page", + "drop-message": "Drop files to attach them to the item", + "upload-successful": "Upload successful", + "upload-failed": "Upload failed", + "header.policy.default.nolist": "Uploaded files in the {{collectionName}} collection will be accessible according to the following group(s):", + "header.policy.default.withlist": "Please note that uploaded files in the {{collectionName}} collection will be accessible, in addition to what is explicitly decided for the single file, with the following group(s):", + "form": { + "access-condition-label": "Access condition type", + "from-label": "Access grant from", + "from-placeholder": "From", + "until-label": "Access grant until", + "until-placeholder": "Until", + "group-label": "Group", + "group-required": "Group is required.", + "date-required": "Date is required." + }, + "save-metadata": "Save metadata", + "undo": "Cancel", + "delete": { + "submit": "Delete", + "confirm": { + "cancel": "Cancel", + "submit": "Yes, I'm sure", + "title": "Delete bitstream", + "info": "This operation can't be undone. Are you sure?" + } + } + } + }, + "workflow": { + "generic": { + "delete": "Delete", + "delete-help": "If you would to discard this item, select \"Delete\". You will then be asked to confirm it.", + "edit": "Edit", + "edit-help": "Select this option to change the item's metadata.", + "view": "View", + "view-help": "Select this option to view the item's metadata." + }, + "tasks": { + "generic": { + "processing": "Processing...", + "success": "Operation successful", + "error": "Error occurred during operation...", + "submitter": "Submitter" + }, + "claimed": { + "approve": "Approve", + "approve_help": "If you have reviewed the item and it is suitable for inclusion in the collection, select \"Approve\".", + "edit": "Edit", + "edit_help": "Select this option to change the item's metadata.", + "reject": { + "submit": "Reject", + "reason": { + "submit": "Reject item", + "title": "Reason", + "info": "Please enter your reason for rejecting the submission into the box below, indicating whether the submitter may fix a problem and resubmit.", + "placeholder": "Describe the reason of reject" + } + }, + "reject_help": "If you have reviewed the item and found it is not suitable for inclusion in the collection, select \"Reject\". You will then be asked to enter a message indicating why the item is unsuitable, and whether the submitter should change something and resubmit.", + "return": "Return to pool", + "return_help": "Return the task to the pool so that another user may perform the task." + + }, + "pool": { + "claim": "Claim", + "claim_help": "Assign this task to yourself.", + "show-detail": "Show detail", + "hide-detail": "Hide detail" + } + } + } + }, + "uploader": { + "drag-message": "Drag & Drop your files here", + "or": ", or", + "browse": "browse", + "queue-lenght": "Queue length", + "processing": "Processing" } } diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 6c3b1fe401..f4dda3b3c8 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -40,8 +40,8 @@ "description": "Beschrijving:" }, "link": { - "simple": "Eenvoudige item weergave", - "full": "Volledige item weergave" + "simple": "Eenvoudige itemweergave", + "full": "Volledige itemweergave" } } }, @@ -52,10 +52,10 @@ }, "pagination": { "results-per-page": "Resultaten per pagina", - "sort-direction": "Sorteer mogelijkheden", + "sort-direction": "Sorteermogelijkheden", "showing": { - "label": "Getoonde items ", - "detail": "{{ range }} tot {{ total }}" + "label": "Resultaten ", + "detail": "{{ range }} van {{ total }}" } }, "sorting": { @@ -116,8 +116,8 @@ "reset": "Filters verwijderen", "applied": { "f.author": "Auteur", - "f.dateIssued.min": "Start datum", - "f.dateIssued.max": "Eind datum", + "f.dateIssued.min": "Startdatum", + "f.dateIssued.max": "Einddatum", "f.subject": "Sleutelwoord", "f.has_content_in_original_bundle": "Heeft bestanden" }, @@ -129,7 +129,7 @@ "head": "Auteur" }, "scope": { - "placeholder": "Bereik filter", + "placeholder": "Bereikfilter", "head": "Bereik" }, "subject": { @@ -159,27 +159,27 @@ "metadata": { "title": "DSpace Angular :: Metadata Register", "head": "Metadata Register", - "description": "Het metadata register omvat de lijst van alle metadata velden die beschikbaar zijn in het systeem. Deze velden kunnen verspreid zijn over verschillende metadata schema's. Het qualified Dublin Core schema (dc) is een verplicht schema en kan niet worden verwijderd.", + "description": "Het metadataregister omvat de lijst van alle metadatavelden die beschikbaar zijn in het systeem. Deze velden kunnen verspreid zijn over verschillende metadataschema's. Het qualified Dublin Core schema (dc) is een verplicht schema en kan niet worden verwijderd.", "schemas": { "table": { "id": "ID", "namespace": "Naamruimte", "name": "Naam" }, - "no-items": "Er kunnen geen metadata schema's getoond worden." + "no-items": "Er kunnen geen metadataschema's getoond worden." } }, "schema": { "title": "DSpace Angular :: Metadata Schema Register", "head": "Metadata Schema", - "description": "Dit is het metadata schema voor \"{{namespace}}\".", + "description": "Dit is het metadataschema voor \"{{namespace}}\".", "fields": { - "head": "Schema metadata velden", + "head": "Schema metadatavelden", "table": { "field": "Veld", "scopenote": "Opmerking over bereik" }, - "no-items": "Er kunnen geen metadata velden getoond worden." + "no-items": "Er kunnen geen metadatavelden getoond worden." } }, "bitstream-formats": { @@ -198,7 +198,7 @@ }, "internal": "intern" }, - "no-items": "Er kunnen geen bitstream formaten getoond worden." + "no-items": "Er kunnen geen bitstreamformaten getoond worden." } } } @@ -229,7 +229,7 @@ "validation": { "pattern": "Deze invoer is niet toegelaten volgens dit patroon: {{ pattern }}.", "license": { - "notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kan dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoer licentie." + "notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kunt dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoerlicentie." } } }, @@ -271,7 +271,7 @@ "expired": "Uw sessie is vervallen. Gelieve opnieuw aan te melden." }, "errors": { - "invalid-user": "Ongeldig email adres of wachtwoord." + "invalid-user": "Ongeldig e-mailadres of wachtwoord." } } } diff --git a/resources/images/orgunit-placeholder.svg b/resources/images/orgunit-placeholder.svg new file mode 100644 index 0000000000..1dae3d607e --- /dev/null +++ b/resources/images/orgunit-placeholder.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/resources/images/person-placeholder.svg b/resources/images/person-placeholder.svg new file mode 100644 index 0000000000..bbe84ec845 --- /dev/null +++ b/resources/images/person-placeholder.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/resources/images/project-placeholder.svg b/resources/images/project-placeholder.svg new file mode 100644 index 0000000000..75ce1003fe --- /dev/null +++ b/resources/images/project-placeholder.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + diff --git a/src/app/+admin/admin-registries/admin-registries.module.ts b/src/app/+admin/admin-registries/admin-registries.module.ts index 8ff42646ac..c7890e6697 100644 --- a/src/app/+admin/admin-registries/admin-registries.module.ts +++ b/src/app/+admin/admin-registries/admin-registries.module.ts @@ -7,6 +7,8 @@ import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component'; import { SharedModule } from '../../shared/shared.module'; +import { MetadataSchemaFormComponent } from './metadata-registry/metadata-schema-form/metadata-schema-form.component'; +import {MetadataFieldFormComponent} from './metadata-schema/metadata-field-form/metadata-field-form.component'; @NgModule({ imports: [ @@ -19,7 +21,12 @@ import { SharedModule } from '../../shared/shared.module'; declarations: [ MetadataRegistryComponent, MetadataSchemaComponent, - BitstreamFormatsComponent + BitstreamFormatsComponent, + MetadataSchemaFormComponent, + MetadataFieldFormComponent + ], + entryComponents: [ + ] }) export class AdminRegistriesModule { diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index 6ba4e8146b..bc0cbb8da6 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -6,13 +6,24 @@ import { PaginatedList } from '../../../core/data/paginated-list'; import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +/** + * This component renders a list of bitstream formats + */ @Component({ selector: 'ds-bitstream-formats', templateUrl: './bitstream-formats.component.html' }) export class BitstreamFormatsComponent { + /** + * A paginated list of bitstream formats to be shown on the page + */ bitstreamFormats: Observable>>; + + /** + * The current pagination configuration for the page + * Currently simply renders all bitstream formats + */ config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'registry-bitstreamformats-pagination', pageSize: 10000 @@ -22,11 +33,18 @@ export class BitstreamFormatsComponent { this.updateFormats(); } + /** + * When the page is changed, make sure to update the list of bitstreams to match the new page + * @param event The page change event + */ onPageChange(event) { this.config.currentPage = event; this.updateFormats(); } + /** + * Method to update the bitstream formats that are shown + */ private updateFormats() { this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config); } diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts new file mode 100644 index 0000000000..7358123462 --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts @@ -0,0 +1,151 @@ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; +import { MetadataField } from '../../../core/metadata/metadatafield.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const MetadataRegistryActionTypes = { + + EDIT_SCHEMA: type('dspace/metadata-registry/EDIT_SCHEMA'), + CANCEL_EDIT_SCHEMA: type('dspace/metadata-registry/CANCEL_SCHEMA'), + SELECT_SCHEMA: type('dspace/metadata-registry/SELECT_SCHEMA'), + DESELECT_SCHEMA: type('dspace/metadata-registry/DESELECT_SCHEMA'), + DESELECT_ALL_SCHEMA: type('dspace/metadata-registry/DESELECT_ALL_SCHEMA'), + + EDIT_FIELD: type('dspace/metadata-registry/EDIT_FIELD'), + CANCEL_EDIT_FIELD: type('dspace/metadata-registry/CANCEL_FIELD'), + SELECT_FIELD: type('dspace/metadata-registry/SELECT_FIELD'), + DESELECT_FIELD: type('dspace/metadata-registry/DESELECT_FIELD'), + DESELECT_ALL_FIELD: type('dspace/metadata-registry/DESELECT_ALL_FIELD') +}; + +/* tslint:disable:max-classes-per-file */ +/** + * Used to edit a metadata schema in the metadata registry + */ +export class MetadataRegistryEditSchemaAction implements Action { + type = MetadataRegistryActionTypes.EDIT_SCHEMA; + + schema: MetadataSchema; + + constructor(registry: MetadataSchema) { + this.schema = registry; + } +} + +/** + * Used to cancel the editing of a metadata schema in the metadata registry + */ +export class MetadataRegistryCancelSchemaAction implements Action { + type = MetadataRegistryActionTypes.CANCEL_EDIT_SCHEMA; +} + +/** + * Used to select a single metadata schema in the metadata registry + */ +export class MetadataRegistrySelectSchemaAction implements Action { + type = MetadataRegistryActionTypes.SELECT_SCHEMA; + + schema: MetadataSchema; + + constructor(registry: MetadataSchema) { + this.schema = registry; + } +} + +/** + * Used to deselect a single metadata schema in the metadata registry + */ +export class MetadataRegistryDeselectSchemaAction implements Action { + type = MetadataRegistryActionTypes.DESELECT_SCHEMA; + + schema: MetadataSchema; + + constructor(registry: MetadataSchema) { + this.schema = registry; + } +} + +/** + * Used to deselect all metadata schemas in the metadata registry + */ +export class MetadataRegistryDeselectAllSchemaAction implements Action { + type = MetadataRegistryActionTypes.DESELECT_ALL_SCHEMA; +} + +/** + * Used to edit a metadata field in the metadata registry + */ +export class MetadataRegistryEditFieldAction implements Action { + type = MetadataRegistryActionTypes.EDIT_FIELD; + + field: MetadataField; + + constructor(registry: MetadataField) { + this.field = registry; + } +} + +/** + * Used to cancel the editing of a metadata field in the metadata registry + */ +export class MetadataRegistryCancelFieldAction implements Action { + type = MetadataRegistryActionTypes.CANCEL_EDIT_FIELD; +} + +/** + * Used to select a single metadata field in the metadata registry + */ +export class MetadataRegistrySelectFieldAction implements Action { + type = MetadataRegistryActionTypes.SELECT_FIELD; + + field: MetadataField; + + constructor(registry: MetadataField) { + this.field = registry; + } +} + +/** + * Used to deselect a single metadata field in the metadata registry + */ +export class MetadataRegistryDeselectFieldAction implements Action { + type = MetadataRegistryActionTypes.DESELECT_FIELD; + + field: MetadataField; + + constructor(registry: MetadataField) { + this.field = registry; + } +} + +/** + * Used to deselect all metadata fields in the metadata registry + */ +export class MetadataRegistryDeselectAllFieldAction implements Action { + type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD; +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + * These are all the actions to perform on the metadata registry state + */ +export type MetadataRegistryAction + = MetadataRegistryEditSchemaAction + | MetadataRegistryCancelSchemaAction + | MetadataRegistrySelectSchemaAction + | MetadataRegistryDeselectSchemaAction + | MetadataRegistryEditFieldAction + | MetadataRegistryCancelFieldAction + | MetadataRegistrySelectFieldAction + | MetadataRegistryDeselectFieldAction; diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html index 49a52cec9c..a254f20428 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html @@ -1,42 +1,61 @@
-
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.scss b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.scss new file mode 100644 index 0000000000..8c208ffad5 --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.scss @@ -0,0 +1,5 @@ +@import '../../../../styles/variables.scss'; + +.selectable-row:hover { + cursor: pointer; +} diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts index 96777116f4..674798848b 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -1,5 +1,5 @@ import { MetadataSchemaComponent } from './metadata-schema.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; @@ -17,6 +17,10 @@ import { HostWindowService } from '../../../shared/host-window.service'; import { RouterStub } from '../../../shared/testing/router-stub'; import { RouterTestingModule } from '@angular/router/testing'; import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { RestResponse } from '../../../core/cache/response.models'; describe('MetadataSchemaComponent', () => { let comp: MetadataSchemaComponent; @@ -38,40 +42,53 @@ describe('MetadataSchemaComponent', () => { ]; const mockFieldsList = [ { + id: 1, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8', element: 'contributor', qualifier: 'advisor', - scopenote: null, + scopeNote: null, schema: mockSchemasList[0] }, { + id: 2, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9', element: 'contributor', qualifier: 'author', - scopenote: null, + scopeNote: null, schema: mockSchemasList[0] }, { + id: 3, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10', element: 'contributor', qualifier: 'editor', - scopenote: 'test scope note', + scopeNote: 'test scope note', schema: mockSchemasList[1] }, { + id: 4, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11', element: 'contributor', qualifier: 'illustrator', - scopenote: null, + scopeNote: null, schema: mockSchemasList[1] } ]; const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList))); + /* tslint:disable:no-empty */ const registryServiceStub = { getMetadataSchemas: () => mockSchemas, getMetadataFieldsBySchema: (schema: MetadataSchema) => observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))), - getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0])) + getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0])), + getActiveMetadataField: () => observableOf(undefined), + getSelectedMetadataFields: () => observableOf([]), + editMetadataField: (schema) => {}, + cancelEditMetadataField: () => {}, + deleteMetadataField: () => observableOf(new RestResponse(true, 200, 'OK')), + deselectAllMetadataField: () => {}, + clearMetadataFieldRequests: () => observableOf(undefined) }; + /* tslint:enable:no-empty */ const schemaNameParam = 'mock'; const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { params: observableOf({ @@ -87,8 +104,10 @@ describe('MetadataSchemaComponent', () => { { provide: RegistryService, useValue: registryServiceStub }, { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, - { provide: Router, useValue: new RouterStub() } - ] + { provide: Router, useValue: new RouterStub() }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() } + ], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); @@ -96,9 +115,12 @@ describe('MetadataSchemaComponent', () => { fixture = TestBed.createComponent(MetadataSchemaComponent); comp = fixture.componentInstance; fixture.detectChanges(); - registryService = (comp as any).service; }); + beforeEach(inject([RegistryService], (s) => { + registryService = s; + })); + it('should contain the schema prefix in the header', () => { const header: HTMLElement = fixture.debugElement.query(By.css('.metadata-schema #header')).nativeElement; expect(header.textContent).toContain('mock'); @@ -110,10 +132,54 @@ describe('MetadataSchemaComponent', () => { }); it('should contain the correct fields', () => { - const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(1)')).nativeElement; + const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(2)')).nativeElement; expect(editorField.textContent).toBe('mock.contributor.editor'); - const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(1)')).nativeElement; + const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(2)')).nativeElement; expect(illustratorField.textContent).toBe('mock.contributor.illustrator'); }); + + describe('when clicking a metadata field row', () => { + let row: HTMLElement; + + beforeEach(() => { + spyOn(registryService, 'editMetadataField'); + row = fixture.debugElement.query(By.css('.selectable-row')).nativeElement; + row.click(); + fixture.detectChanges(); + }); + + it('should start editing the selected field', async(() => { + fixture.whenStable().then(() => { + expect(registryService.editMetadataField).toHaveBeenCalledWith(mockFieldsList[2]); + }); + })); + + it('should cancel editing the selected field when clicked again', async(() => { + spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(mockFieldsList[2])); + spyOn(registryService, 'cancelEditMetadataField'); + row.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(registryService.cancelEditMetadataField).toHaveBeenCalled(); + }); + })); + }); + + describe('when deleting metadata fields', () => { + const selectedFields = Array(mockFieldsList[2]); + + beforeEach(() => { + spyOn(registryService, 'deleteMetadataField').and.callThrough(); + spyOn(registryService, 'getSelectedMetadataFields').and.returnValue(observableOf(selectedFields)); + comp.deleteFields(); + fixture.detectChanges(); + }); + + it('should call deleteMetadataField with the selected id', async(() => { + fixture.whenStable().then(() => { + expect(registryService.deleteMetadataField).toHaveBeenCalledWith(selectedFields[0].id); + }); + })); + }); }); diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts index 6c5e01b37d..bdc7d5ed27 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -1,29 +1,59 @@ import { Component, OnInit } from '@angular/core'; import { RegistryService } from '../../../core/registry/registry.service'; -import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable, combineLatest as observableCombineLatest } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { MetadataField } from '../../../core/metadata/metadatafield.model'; import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { map, take } from 'rxjs/operators'; +import { hasValue } from '../../../shared/empty.util'; +import { RestResponse } from '../../../core/cache/response.models'; +import { zip } from 'rxjs/internal/observable/zip'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'ds-metadata-schema', - templateUrl: './metadata-schema.component.html' + templateUrl: './metadata-schema.component.html', + styleUrls: ['./metadata-schema.component.scss'] }) +/** + * A component used for managing all existing metadata fields within the current metadata schema. + * The admin can create, edit or delete metadata fields here. + */ export class MetadataSchemaComponent implements OnInit { + /** + * The namespace of the metadata schema + */ namespace; + /** + * The metadata schema + */ metadataSchema: Observable>; + + /** + * A list of all the fields attached to this metadata schema + */ metadataFields: Observable>>; + + /** + * Pagination config used to display the list of metadata fields + */ config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'registry-metadatafields-pagination', - pageSize: 10000 + pageSize: 25, + pageSizeOptions: [25, 50, 100, 200] }); - constructor(private registryService: RegistryService, private route: ActivatedRoute) { + constructor(private registryService: RegistryService, + private route: ActivatedRoute, + private notificationsService: NotificationsService, + private router: Router, + private translateService: TranslateService) { } @@ -33,22 +63,143 @@ export class MetadataSchemaComponent implements OnInit { }); } + /** + * Initialize the component using the params within the url (schemaName) + * @param params + */ initialize(params) { this.metadataSchema = this.registryService.getMetadataSchemaByName(params.schemaName); this.updateFields(); } + /** + * Event triggered when the user changes page + * @param event + */ onPageChange(event) { this.config.currentPage = event; this.updateFields(); } + /** + * Update the list of fields by fetching it from the rest api or cache + */ private updateFields() { this.metadataSchema.subscribe((schemaData) => { const schema = schemaData.payload; this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config); - this.namespace = { namespace: schemaData.payload.namespace }; + this.namespace = {namespace: schemaData.payload.namespace}; }); } + /** + * Force-update the list of fields by first clearing the cache related to metadata fields, then performing + * a new REST call + */ + public forceUpdateFields() { + this.registryService.clearMetadataFieldRequests().subscribe(); + this.updateFields(); + } + + /** + * Start editing the selected metadata field + * @param field + */ + editField(field: MetadataField) { + this.getActiveField().pipe(take(1)).subscribe((activeField) => { + if (field === activeField) { + this.registryService.cancelEditMetadataField(); + } else { + this.registryService.editMetadataField(field); + } + }); + } + + /** + * Checks whether the given metadata field is active (being edited) + * @param field + */ + isActive(field: MetadataField): Observable { + return this.getActiveField().pipe( + map((activeField) => field === activeField) + ); + } + + /** + * Gets the active metadata field (being edited) + */ + getActiveField(): Observable { + return this.registryService.getActiveMetadataField(); + } + + /** + * Select a metadata field within the list (checkbox) + * @param field + * @param event + */ + selectMetadataField(field: MetadataField, event) { + event.target.checked ? + this.registryService.selectMetadataField(field) : + this.registryService.deselectMetadataField(field); + } + + /** + * Checks whether a given metadata field is selected in the list (checkbox) + * @param field + */ + isSelected(field: MetadataField): Observable { + return this.registryService.getSelectedMetadataFields().pipe( + map((fields) => fields.find((selectedField) => selectedField === field) != null) + ); + } + + /** + * Delete all the selected metadata fields + */ + deleteFields() { + this.registryService.getSelectedMetadataFields().pipe(take(1)).subscribe( + (fields) => { + const tasks$ = []; + for (const field of fields) { + if (hasValue(field.id)) { + tasks$.push(this.registryService.deleteMetadataField(field.id)); + } + } + zip(...tasks$).subscribe((responses: RestResponse[]) => { + const successResponses = responses.filter((response: RestResponse) => response.isSuccessful); + const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful); + if (successResponses.length > 0) { + this.showNotification(true, successResponses.length); + } + if (failedResponses.length > 0) { + this.showNotification(false, failedResponses.length); + } + this.registryService.deselectAllMetadataField(); + this.registryService.cancelEditMetadataField(); + this.forceUpdateFields(); + }); + } + ) + } + + /** + * Show notifications for an amount of deleted metadata fields + * @param success Whether or not the notification should be a success message (error message when false) + * @param amount The amount of deleted metadata fields + */ + showNotification(success: boolean, amount: number) { + const prefix = 'admin.registries.schema.notification'; + const suffix = success ? 'success' : 'failure'; + const messages = observableCombineLatest( + this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), + this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount }) + ); + messages.subscribe(([head, content]) => { + if (success) { + this.notificationsService.success(head, content) + } else { + this.notificationsService.error(head, content) + } + }); + } } diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index e7c96bb9c4..71af51c683 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -4,7 +4,10 @@ import { NgModule } from '@angular/core'; @NgModule({ imports: [ RouterModule.forChild([ - { path: 'registries', loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' } + { + path: 'registries', + loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' + } ]) ] }) diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts index c99e8adc58..590caaaccf 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -12,6 +12,7 @@ import { AuthService } from '../../core/auth/auth.service'; import { of as observableOf } from 'rxjs'; import { By } from '@angular/platform-browser'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; describe('AdminSidebarComponent', () => { let comp: AdminSidebarComponent; @@ -26,7 +27,12 @@ describe('AdminSidebarComponent', () => { { provide: Injector, useValue: {} }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, - { provide: AuthService, useClass: AuthServiceStub } + { provide: AuthService, useClass: AuthServiceStub }, + { + provide: NgbModal, useValue: { + open: () => {/*comment*/} + } + } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(AdminSidebarComponent, { @@ -96,7 +102,10 @@ describe('AdminSidebarComponent', () => { beforeEach(() => { spyOn(menuService, 'toggleMenu'); const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon')); - sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}}); + sidebarToggler.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); }); it('should call toggleMenu on the menuService', () => { @@ -108,7 +117,10 @@ describe('AdminSidebarComponent', () => { beforeEach(() => { spyOn(menuService, 'toggleMenu'); const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a')); - sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}}); + sidebarToggler.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); }); it('should call toggleMenu on the menuService', () => { @@ -120,7 +132,10 @@ describe('AdminSidebarComponent', () => { it('should call expandPreview on the menuService after 100ms', fakeAsync(() => { spyOn(menuService, 'expandMenuPreview'); const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar')); - sidebarToggler.triggerEventHandler('mouseenter', {preventDefault: () => {/**/}}); + sidebarToggler.triggerEventHandler('mouseenter', { + preventDefault: () => {/**/ + } + }); tick(99); expect(menuService.expandMenuPreview).not.toHaveBeenCalled(); tick(1); @@ -132,7 +147,10 @@ describe('AdminSidebarComponent', () => { it('should call collapseMenuPreview on the menuService after 400ms', fakeAsync(() => { spyOn(menuService, 'collapseMenuPreview'); const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar')); - sidebarToggler.triggerEventHandler('mouseleave', {preventDefault: () => {/**/}}); + sidebarToggler.triggerEventHandler('mouseleave', { + preventDefault: () => {/**/ + } + }); tick(399); expect(menuService.collapseMenuPreview).not.toHaveBeenCalled(); tick(1); diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index eb48f64d4d..3ad1bd4272 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -1,6 +1,6 @@ import { Component, Injector, OnInit } from '@angular/core'; import { Observable } from 'rxjs/internal/Observable'; -import { slide, slideHorizontal, slideSidebar } from '../../shared/animations/slide'; +import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; import { MenuService } from '../../shared/menu/menu.service'; import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state'; @@ -10,6 +10,14 @@ import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model import { AuthService } from '../../core/auth/auth.service'; import { first, map } from 'rxjs/operators'; import { combineLatest as combineLatestObservable } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; +import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; /** * Component representing the admin sidebar @@ -52,7 +60,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { constructor(protected menuService: MenuService, protected injector: Injector, private variableService: CSSVariableService, - private authService: AuthService + private authService: AuthService, + private modalService: NgbModal ) { super(menuService, injector); } @@ -104,10 +113,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { active: false, visible: true, model: { - type: MenuItemType.LINK, + type: MenuItemType.ONCLICK, text: 'menu.section.new_community', - link: '/communities/submission' - } as LinkMenuItemModel, + function: () => { + this.modalService.open(CreateCommunityParentSelectorComponent); + } + } as OnClickMenuItemModel, }, { id: 'new_collection', @@ -115,20 +126,29 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { active: false, visible: true, model: { - type: MenuItemType.LINK, + type: MenuItemType.ONCLICK, text: 'menu.section.new_collection', - link: '/collections/submission' - } as LinkMenuItemModel, + function: () => { + this.modalService.open(CreateCollectionParentSelectorComponent); + } + } as OnClickMenuItemModel, }, { id: 'new_item', parentID: 'new', active: false, visible: true, + // model: { + // type: MenuItemType.ONCLICK, + // text: 'menu.section.new_item', + // function: () => { + // this.modalService.open(CreateItemParentSelectorComponent); + // } + // } as OnClickMenuItemModel, model: { type: MenuItemType.LINK, text: 'menu.section.new_item', - link: '/items/submission' + link: '/submit' } as LinkMenuItemModel, }, { @@ -139,7 +159,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.new_item_version', - link: '#' + link: '' } as LinkMenuItemModel, }, @@ -161,10 +181,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { active: false, visible: true, model: { - type: MenuItemType.LINK, + type: MenuItemType.ONCLICK, text: 'menu.section.edit_community', - link: '#' - } as LinkMenuItemModel, + function: () => { + this.modalService.open(EditCommunitySelectorComponent); + } + } as OnClickMenuItemModel, }, { id: 'edit_collection', @@ -172,10 +194,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { active: false, visible: true, model: { - type: MenuItemType.LINK, + type: MenuItemType.ONCLICK, text: 'menu.section.edit_collection', - link: '#' - } as LinkMenuItemModel, + function: () => { + this.modalService.open(EditCollectionSelectorComponent); + } + } as OnClickMenuItemModel, }, { id: 'edit_item', @@ -183,10 +207,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { active: false, visible: true, model: { - type: MenuItemType.LINK, + type: MenuItemType.ONCLICK, text: 'menu.section.edit_item', - link: '#' - } as LinkMenuItemModel, + function: () => { + this.modalService.open(EditItemSelectorComponent); + } + } as OnClickMenuItemModel, }, /* Import */ @@ -209,7 +235,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.import_metadata', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -220,10 +246,9 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.import_batch', - link: '#' + link: '' } as LinkMenuItemModel, }, - /* Export */ { id: 'export', @@ -244,7 +269,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_community', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -255,7 +280,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_collection', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -266,7 +291,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_item', - link: '#' + link: '' } as LinkMenuItemModel, }, { id: 'export_metadata', @@ -276,7 +301,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_metadata', - link: '#' + link: '' } as LinkMenuItemModel, }, @@ -300,7 +325,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_people', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -311,7 +336,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_groups', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -322,7 +347,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_authorizations', - link: '#' + link: '' } as LinkMenuItemModel, }, @@ -357,7 +382,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.find_withdrawn_items', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -368,7 +393,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.find_private_items', - link: '/admin/items' + link: '' } as LinkMenuItemModel, }, @@ -415,7 +440,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.curation_task', - link: '/curation' + link: '' } as LinkMenuItemModel, icon: 'filter', index: 7 @@ -429,7 +454,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.statistics_task', - link: '#' + link: '' } as LinkMenuItemModel, icon: 'chart-bar', index: 8 @@ -443,7 +468,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.control_panel', - link: '#' + link: '' } as LinkMenuItemModel, icon: 'cogs', index: 9 diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts index 4921be77e2..112560de16 100644 --- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -18,8 +18,8 @@ import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorat templateUrl: './expandable-admin-sidebar-section.component.html', styleUrls: ['./expandable-admin-sidebar-section.component.scss'], animations: [rotate, slide, bgColor] - }) + @rendersSectionForMenu(MenuID.ADMIN, true) export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit { /** diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index 41d00223ab..1495d0fd8c 100644 --- a/src/app/+admin/admin.module.ts +++ b/src/app/+admin/admin.module.ts @@ -1,12 +1,14 @@ import { NgModule } from '@angular/core'; import { AdminRegistriesModule } from './admin-registries/admin-registries.module'; import { AdminRoutingModule } from './admin-routing.module'; +import { SharedModule } from '../shared/shared.module'; @NgModule({ imports: [ AdminRegistriesModule, AdminRoutingModule, - ] + SharedModule, + ], }) export class AdminModule { diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html deleted file mode 100644 index 438c318994..0000000000 --- a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- - -
-
diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts deleted file mode 100644 index 813ee8a32f..0000000000 --- a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts +++ /dev/null @@ -1,107 +0,0 @@ - -import {combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { Component, OnInit } from '@angular/core'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list'; -import { ItemDataService } from '../../core/data/item-data.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { ActivatedRoute } from '@angular/router'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { BrowseService } from '../../core/browse/browse.service'; -import { BrowseEntry } from '../../core/shared/browse-entry.model'; -import { Item } from '../../core/shared/item.model'; - -@Component({ - selector: 'ds-browse-by-author-page', - styleUrls: ['./browse-by-author-page.component.scss'], - templateUrl: './browse-by-author-page.component.html' -}) -/** - * Component for browsing (items) by author (dc.contributor.author) - */ -export class BrowseByAuthorPageComponent implements OnInit { - - authors$: Observable>>; - items$: Observable>>; - paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'browse-by-author-pagination', - currentPage: 1, - pageSize: 20 - }); - sortConfig: SortOptions = new SortOptions('dc.contributor.author', SortDirection.ASC); - subs: Subscription[] = []; - currentUrl: string; - value = ''; - - public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute, private browseService: BrowseService) { - } - - ngOnInit(): void { - this.currentUrl = this.route.snapshot.pathFromRoot - .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') - .join('/'); - this.updatePage({ - pagination: this.paginationConfig, - sort: this.sortConfig - }); - this.subs.push( - observableCombineLatest( - this.route.params, - this.route.queryParams, - (params, queryParams, ) => { - return Object.assign({}, params, queryParams); - }) - .subscribe((params) => { - const page = +params.page || this.paginationConfig.currentPage; - const pageSize = +params.pageSize || this.paginationConfig.pageSize; - const sortDirection = params.sortDirection || this.sortConfig.direction; - const sortField = params.sortField || this.sortConfig.field; - this.value = +params.value || params.value || ''; - const pagination = Object.assign({}, - this.paginationConfig, - { currentPage: page, pageSize: pageSize } - ); - const sort = Object.assign({}, - this.sortConfig, - { direction: sortDirection, field: sortField } - ); - const searchOptions = { - pagination: pagination, - sort: sort - }; - if (isNotEmpty(this.value)) { - this.updatePageWithItems(searchOptions, this.value); - } else { - this.updatePage(searchOptions); - } - })); - } - - /** - * Updates the current page with searchOptions - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - */ - updatePage(searchOptions) { - this.authors$ = this.browseService.getBrowseEntriesFor('author', searchOptions); - this.items$ = undefined; - } - - /** - * Updates the current page with searchOptions and display items linked to author - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - * @param author The author's name for displaying items - */ - updatePageWithItems(searchOptions, author: string) { - this.items$ = this.browseService.getBrowseItemsFor('author', author, searchOptions); - } - - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - } - -} diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts new file mode 100644 index 0000000000..ccf7cde67b --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts @@ -0,0 +1,104 @@ +import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../core/data/remote-data'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { Community } from '../../core/shared/community.model'; +import { Item } from '../../core/shared/item.model'; +import { ENV_CONFIG, GLOBAL_CONFIG } from '../../../config'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec'; + +describe('BrowseByDatePageComponent', () => { + let comp: BrowseByDatePageComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + metadata: [ + { + key: 'dc.title', + value: 'test community' + } + ] + }); + + const firstItem = Object.assign(new Item(), { + id: 'first-item-id', + metadata: { + 'dc.date.issued': [ + { + value: '1950-01-01' + } + ] + } + }); + + const mockBrowseService = { + getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), + getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), + getFirstItemFor: () => observableOf(new RemoteData(false, false, true, undefined, firstItem)) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}), + data: observableOf({ metadata: 'dateissued', metadataField: 'dc.date.issued' }) + }); + + const mockCdRef = Object.assign({ + detectChanges: () => fixture.detectChanges() + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByDatePageComponent, EnumKeysPipe], + providers: [ + { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() }, + { provide: ChangeDetectorRef, useValue: mockCdRef } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByDatePageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + }); + + it('should initialize the list of items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual([firstItem]); + }); + }); + + it('should create a list of startsWith options with the earliest year at the end (rounded down by 10)', () => { + expect(comp.startsWithOptions[comp.startsWithOptions.length - 1]).toEqual(1950); + }); + + it('should create a list of startsWith options with the current year first', () => { + expect(comp.startsWithOptions[0]).toEqual(new Date().getFullYear()); + }); +}); diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts new file mode 100644 index 0000000000..2acd96adb0 --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -0,0 +1,115 @@ +import { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions +} from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; + +@Component({ + selector: 'ds-browse-by-date-page', + styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'], + templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html' +}) +/** + * Component for browsing items by metadata definition of type 'date' + * A metadata definition is a short term used to describe one or multiple metadata fields. + * An example would be 'dateissued' for 'dc.date.issued' + */ +export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { + + /** + * The default metadata-field to use for determining the lower limit of the StartsWith dropdown options + */ + defaultMetadataField = 'dc.date.issued'; + + public constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig, + protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router, + protected cdRef: ChangeDetectorRef) { + super(route, browseService, dsoService, router); + } + + ngOnInit(): void { + this.startsWithType = StartsWithType.date; + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); + }) + .subscribe((params) => { + const metadataField = params.metadataField || this.defaultMetadataField; + this.metadata = params.metadata || this.defaultMetadata; + this.startsWith = +params.startsWith || params.startsWith; + const searchOptions = browseParamsToOptions(params, Object.assign({}), this.sortConfig, this.metadata); + this.updatePageWithItems(searchOptions, this.value); + this.updateParent(params.scope); + this.updateStartsWithOptions(this.metadata, metadataField, params.scope); + })); + } + + /** + * Update the StartsWith options + * In this implementation, it creates a list of years starting from now, going all the way back to the earliest + * date found on an item within this scope. The further back in time, the bigger the change in years become to avoid + * extremely long lists with a one-year difference. + * To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this. + * @param definition The metadata definition to fetch the first item for + * @param metadataField The metadata field to fetch the earliest date from (expects a date field) + * @param scope The scope under which to fetch the earliest item for + */ + updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { + this.subs.push( + this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData) => { + let lowerLimit = this.config.browseBy.defaultLowerLimit; + if (hasValue(firstItemRD.payload)) { + const date = firstItemRD.payload.firstMetadataValue(metadataField); + if (hasValue(date) && hasValue(+date.split('-')[0])) { + lowerLimit = +date.split('-')[0]; + } + } + const options = []; + const currentYear = new Date().getFullYear(); + const oneYearBreak = Math.floor((currentYear - this.config.browseBy.oneYearLimit) / 5) * 5; + const fiveYearBreak = Math.floor((currentYear - this.config.browseBy.fiveYearLimit) / 10) * 10; + if (lowerLimit <= fiveYearBreak) { + lowerLimit -= 10; + } else if (lowerLimit <= oneYearBreak) { + lowerLimit -= 5; + } else { + lowerLimit -= 1; + } + let i = currentYear; + while (i > lowerLimit) { + options.push(i); + if (i <= fiveYearBreak) { + i -= 10; + } else if (i <= oneYearBreak) { + i -= 5; + } else { + i--; + } + } + if (isNotEmpty(options)) { + this.startsWithOptions = options; + this.cdRef.detectChanges(); + } + }) + ); + } + +} diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html new file mode 100644 index 0000000000..cf43f74eb0 --- /dev/null +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -0,0 +1,18 @@ +
+ +
diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.scss similarity index 100% rename from src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss rename to src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.scss diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts new file mode 100644 index 0000000000..98d7299984 --- /dev/null +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts @@ -0,0 +1,164 @@ +import { BrowseByMetadataPageComponent, browseParamsToOptions } from './browse-by-metadata-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowseService } from '../../core/browse/browse.service'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { SortDirection } from '../../core/cache/models/sort-options.model'; +import { Item } from '../../core/shared/item.model'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { Community } from '../../core/shared/community.model'; +import { MockRouter } from '../../shared/mocks/mock-router'; + +describe('BrowseByMetadataPageComponent', () => { + let comp: BrowseByMetadataPageComponent; + let fixture: ComponentFixture; + let browseService: BrowseService; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + metadata: [ + { + key: 'dc.title', + value: 'test community' + } + ] + }); + + const mockEntries = [ + { + type: 'author', + authority: null, + value: 'John Doe', + language: 'en', + count: 1 + }, + { + type: 'author', + authority: null, + value: 'James Doe', + language: 'en', + count: 3 + }, + { + type: 'subject', + authority: null, + value: 'Fake subject', + language: 'en', + count: 2 + } + ]; + + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId' + }) + ]; + + const mockBrowseService = { + getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData(mockEntries.filter((entry) => entry.type === options.metadataDefinition)), + getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData(mockItems) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByMetadataPageComponent, EnumKeysPipe], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByMetadataPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + browseService = (comp as any).browseService; + route = (comp as any).route; + route.params = observableOf({}); + comp.ngOnInit(); + fixture.detectChanges(); + }); + + it('should fetch the correct entries depending on the metadata definition', () => { + comp.browseEntries$.subscribe((result) => { + expect(result.payload.page).toEqual(mockEntries.filter((entry) => entry.type === 'author')); + }); + }); + + it('should not fetch any items when no value is provided', () => { + expect(comp.items$).toBeUndefined(); + }); + + describe('when a value is provided', () => { + beforeEach(() => { + const paramsWithValue = { + metadata: 'author', + value: 'John Doe' + }; + + route.params = observableOf(paramsWithValue); + comp.ngOnInit(); + }); + + it('should fetch items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual(mockItems); + }); + }) + }); + + describe('when calling browseParamsToOptions', () => { + let result: BrowseEntrySearchOptions; + + beforeEach(() => { + const paramsWithPaginationAndScope = { + page: 5, + pageSize: 10, + sortDirection: SortDirection.ASC, + sortField: 'fake-field', + scope: 'fake-scope' + }; + + result = browseParamsToOptions(paramsWithPaginationAndScope, Object.assign({}), Object.assign({}), 'author'); + }); + + it('should return BrowseEntrySearchOptions with the correct properties', () => { + expect(result.metadataDefinition).toEqual('author'); + expect(result.pagination.currentPage).toEqual(5); + expect(result.pagination.pageSize).toEqual(10); + expect(result.sort.direction).toEqual(SortDirection.ASC); + expect(result.sort.field).toEqual('fake-field'); + expect(result.scope).toEqual('fake-scope'); + }) + }); +}); + +export function toRemoteData(objects: any[]): Observable>> { + return observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), objects))); +} diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts new file mode 100644 index 0000000000..52c63341bd --- /dev/null +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -0,0 +1,263 @@ +import {combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { BrowseService } from '../../core/browse/browse.service'; +import { BrowseEntry } from '../../core/shared/browse-entry.model'; +import { Item } from '../../core/shared/item.model'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { getSucceededRemoteData } from '../../core/shared/operators'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { take } from 'rxjs/operators'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; + +@Component({ + selector: 'ds-browse-by-metadata-page', + styleUrls: ['./browse-by-metadata-page.component.scss'], + templateUrl: './browse-by-metadata-page.component.html' +}) +/** + * Component for browsing (items) by metadata definition + * A metadata definition is a short term used to describe one or multiple metadata fields. + * An example would be 'author' for 'dc.contributor.*' + */ +export class BrowseByMetadataPageComponent implements OnInit { + + /** + * The list of browse-entries to display + */ + browseEntries$: Observable>>; + + /** + * The list of items to display when a value is present + */ + items$: Observable>>; + + /** + * The current Community or Collection we're browsing metadata/items in + */ + parent$: Observable>; + + /** + * The pagination config used to display the values + */ + paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'browse-by-metadata-pagination', + currentPage: 1, + pageSize: 20 + }); + + /** + * The sorting config used to sort the values (defaults to Ascending) + */ + sortConfig: SortOptions = new SortOptions('default', SortDirection.ASC); + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + /** + * The default metadata definition to resort to when none is provided + */ + defaultMetadata = 'author'; + + /** + * The current metadata definition + */ + metadata = this.defaultMetadata; + + /** + * The type of StartsWith options to render + * Defaults to text + */ + startsWithType = StartsWithType.text; + + /** + * The list of StartsWith options + * Should be defined after ngOnInit is called! + */ + startsWithOptions; + + /** + * The value we're browing items for + * - When the value is not empty, we're browsing items + * - When the value is empty, we're browsing browse-entries (values for the given metadata definition) + */ + value = ''; + + /** + * The current startsWith option (fetched and updated from query-params) + */ + startsWith: string; + + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router) { + } + + ngOnInit(): void { + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + (params, queryParams, ) => { + return Object.assign({}, params, queryParams); + }) + .subscribe((params) => { + this.metadata = params.metadata || this.defaultMetadata; + this.value = +params.value || params.value || ''; + this.startsWith = +params.startsWith || params.startsWith; + const searchOptions = browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata); + if (isNotEmpty(this.value)) { + this.updatePageWithItems(searchOptions, this.value); + } else { + this.updatePage(searchOptions); + } + this.updateParent(params.scope); + })); + this.updateStartsWithTextOptions(); + } + + /** + * Update the StartsWith options with text values + * It adds the value "0-9" as well as all letters from A to Z + */ + updateStartsWithTextOptions() { + this.startsWithOptions = ['0-9', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; + } + + /** + * Updates the current page with searchOptions + * @param searchOptions Options to narrow down your search: + * { metadata: string + * pagination: PaginationComponentOptions, + * sort: SortOptions, + * scope: string } + */ + updatePage(searchOptions: BrowseEntrySearchOptions) { + this.browseEntries$ = this.browseService.getBrowseEntriesFor(searchOptions); + this.items$ = undefined; + } + + /** + * Updates the current page with searchOptions and display items linked to the given value + * @param searchOptions Options to narrow down your search: + * { metadata: string + * pagination: PaginationComponentOptions, + * sort: SortOptions, + * scope: string } + * @param value The value of the browse-entry to display items for + */ + updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) { + this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions); + } + + /** + * Update the parent Community or Collection using their scope + * @param scope The UUID of the Community or Collection to fetch + */ + updateParent(scope: string) { + if (hasValue(scope)) { + this.parent$ = this.dsoService.findById(scope).pipe( + getSucceededRemoteData() + ); + } + } + + /** + * Navigate to the previous page + */ + goPrev() { + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getPrevBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getPrevBrowseEntries(entries); + }); + } + } + + /** + * Navigate to the next page + */ + goNext() { + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getNextBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getNextBrowseEntries(entries); + }); + } + } + + /** + * Change the page size + * @param size + */ + pageSizeChange(size) { + this.router.navigate([], { + queryParams: Object.assign({ pageSize: size }), + queryParamsHandling: 'merge' + }); + } + + /** + * Change the sorting direction + * @param direction + */ + sortDirectionChange(direction) { + this.router.navigate([], { + queryParams: Object.assign({ sortDirection: direction }), + queryParamsHandling: 'merge' + }); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + +} + +/** + * Function to transform query and url parameters into searchOptions used to fetch browse entries or items + * @param params URL and query parameters + * @param paginationConfig Pagination configuration + * @param sortConfig Sorting configuration + * @param metadata Optional metadata definition to fetch browse entries/items for + */ +export function browseParamsToOptions(params: any, + paginationConfig: PaginationComponentOptions, + sortConfig: SortOptions, + metadata?: string): BrowseEntrySearchOptions { + return new BrowseEntrySearchOptions( + metadata, + Object.assign({}, + paginationConfig, + { + currentPage: +params.page || paginationConfig.currentPage, + pageSize: +params.pageSize || paginationConfig.pageSize + } + ), + Object.assign({}, + sortConfig, + { + direction: params.sortDirection || sortConfig.direction, + field: params.sortField || sortConfig.field + } + ), + +params.startsWith || params.startsWith, + params.scope + ); +} diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html deleted file mode 100644 index d37727be36..0000000000 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- - -
-
diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts new file mode 100644 index 0000000000..855101bb9d --- /dev/null +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts @@ -0,0 +1,90 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Item } from '../../core/shared/item.model'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec'; +import { BrowseByTitlePageComponent } from './browse-by-title-page.component'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { Community } from '../../core/shared/community.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { BrowseService } from '../../core/browse/browse.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; + +describe('BrowseByTitlePageComponent', () => { + let comp: BrowseByTitlePageComponent; + let fixture: ComponentFixture; + let itemDataService: ItemDataService; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + metadata: [ + { + key: 'dc.title', + value: 'test community' + } + ] + }); + + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId', + metadata: [ + { + key: 'dc.title', + value: 'Fake Title' + } + ] + }) + ]; + + const mockBrowseService = { + getBrowseItemsFor: () => toRemoteData(mockItems), + getBrowseEntriesFor: () => toRemoteData([]) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + data: observableOf({ metadata: 'title' }) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByTitlePageComponent, EnumKeysPipe], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByTitlePageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + itemDataService = (comp as any).itemDataService; + route = (comp as any).route; + }); + + it('should initialize the list of items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual(mockItems); + }); + }); +}); diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts index e9127dbbab..717275bf8b 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -1,88 +1,51 @@ - -import {combineLatest as observableCombineLatest, Observable , Subscription } from 'rxjs'; -import { Component, OnInit } from '@angular/core'; -import { RemoteData } from '../../core/data/remote-data'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { PaginatedList } from '../../core/data/paginated-list'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { Component } from '@angular/core'; import { ItemDataService } from '../../core/data/item-data.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { Item } from '../../core/shared/item.model'; -import { ActivatedRoute, PRIMARY_OUTLET, UrlSegmentGroup } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { hasValue } from '../../shared/empty.util'; -import { Collection } from '../../core/shared/collection.model'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions +} from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { BrowseService } from '../../core/browse/browse.service'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-browse-by-title-page', - styleUrls: ['./browse-by-title-page.component.scss'], - templateUrl: './browse-by-title-page.component.html' + styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'], + templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html' }) /** * Component for browsing items by title (dc.title) */ -export class BrowseByTitlePageComponent implements OnInit { - - items$: Observable>>; - paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'browse-by-title-pagination', - currentPage: 1, - pageSize: 20 - }); - sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC); - subs: Subscription[] = []; - currentUrl: string; - - public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute) { +export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router) { + super(route, browseService, dsoService, router); } ngOnInit(): void { - this.currentUrl = this.route.snapshot.pathFromRoot - .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') - .join('/'); - this.updatePage({ - pagination: this.paginationConfig, - sort: this.sortConfig - }); + this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); this.subs.push( observableCombineLatest( this.route.params, this.route.queryParams, - (params, queryParams, ) => { - return Object.assign({}, params, queryParams); + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); }) .subscribe((params) => { - const page = +params.page || this.paginationConfig.currentPage; - const pageSize = +params.pageSize || this.paginationConfig.pageSize; - const sortDirection = params.sortDirection || this.sortConfig.direction; - const sortField = params.sortField || this.sortConfig.field; - const pagination = Object.assign({}, - this.paginationConfig, - { currentPage: page, pageSize: pageSize } - ); - const sort = Object.assign({}, - this.sortConfig, - { direction: sortDirection, field: sortField } - ); - this.updatePage({ - pagination: pagination, - sort: sort - }); + this.metadata = params.metadata || this.defaultMetadata; + this.updatePageWithItems(browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata), undefined); + this.updateParent(params.scope) })); - } - - /** - * Updates the current page with searchOptions - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - */ - updatePage(searchOptions) { - this.items$ = this.itemDataService.findAll({ - currentPage: searchOptions.pagination.currentPage, - elementsPerPage: searchOptions.pagination.pageSize, - sort: searchOptions.sort - }); + this.updateStartsWithTextOptions(); } ngOnDestroy(): void { diff --git a/src/app/+browse-by/browse-by-guard.spec.ts b/src/app/+browse-by/browse-by-guard.spec.ts new file mode 100644 index 0000000000..784f9ea22d --- /dev/null +++ b/src/app/+browse-by/browse-by-guard.spec.ts @@ -0,0 +1,125 @@ +import { first } from 'rxjs/operators'; +import { BrowseByGuard } from './browse-by-guard'; +import { of as observableOf } from 'rxjs'; + +describe('BrowseByGuard', () => { + describe('canActivate', () => { + let guard: BrowseByGuard; + let dsoService: any; + let translateService: any; + + const name = 'An interesting DSO'; + const title = 'Author'; + const field = 'Author'; + const metadata = 'author'; + const metadataField = 'dc.contributor'; + const scope = '1234-65487-12354-1235'; + const value = 'Filter'; + + beforeEach(() => { + dsoService = { + findById: (id: string) => observableOf({ payload: { name: name }, hasSucceeded: true }) + }; + + translateService = { + instant: () => field + }; + guard = new BrowseByGuard(dsoService, translateService); + }); + + it('should return true, and sets up the data correctly, with a scope and value', () => { + const scopedRoute = { + data: { + title: field, + metadataField, + }, + params: { + metadata, + }, + queryParams: { + scope, + value + } + }; + guard.canActivate(scopedRoute as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => { + const result = { + title, + metadata, + metadataField, + collection: name, + field, + value: '"' + value + '"' + }; + expect(scopedRoute.data).toEqual(result); + expect(canActivate).toEqual(true); + } + ); + }); + + it('should return true, and sets up the data correctly, with a scope and without value', () => { + const scopedNoValueRoute = { + data: { + title: field, + metadataField, + }, + params: { + metadata, + }, + queryParams: { + scope + } + }; + + guard.canActivate(scopedNoValueRoute as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => { + const result = { + title, + metadata, + metadataField, + collection: name, + field, + value: '' + }; + expect(scopedNoValueRoute.data).toEqual(result); + expect(canActivate).toEqual(true); + } + ); + }); + + it('should return true, and sets up the data correctly, without a scope and with a value', () => { + const route = { + data: { + title: field, + metadataField, + }, + params: { + metadata, + }, + queryParams: { + value + } + }; + guard.canActivate(route as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => { + const result = { + title, + metadata, + metadataField, + collection: '', + field, + value: '"' + value + '"' + }; + expect(route.data).toEqual(result); + expect(canActivate).toEqual(true); + } + ); + }); + }); +}); diff --git a/src/app/+browse-by/browse-by-guard.ts b/src/app/+browse-by/browse-by-guard.ts new file mode 100644 index 0000000000..5d3dad2b0f --- /dev/null +++ b/src/app/+browse-by/browse-by-guard.ts @@ -0,0 +1,52 @@ +import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; +import { Injectable } from '@angular/core'; +import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; +import { hasValue } from '../shared/empty.util'; +import { map } from 'rxjs/operators'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +@Injectable() +/** + * A guard taking care of the correct route.data being set for the Browse-By components + */ +export class BrowseByGuard implements CanActivate { + + constructor(protected dsoService: DSpaceObjectDataService, + protected translate: TranslateService) { + } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + const title = route.data.title; + const metadata = route.params.metadata || route.queryParams.metadata || route.data.metadata; + const metadataField = route.data.metadataField; + const scope = route.queryParams.scope; + const value = route.queryParams.value; + const metadataTranslated = this.translate.instant('browse.metadata.' + metadata); + if (hasValue(scope)) { + const dsoAndMetadata$ = this.dsoService.findById(scope).pipe(getSucceededRemoteData()); + return dsoAndMetadata$.pipe( + map((dsoRD) => { + const name = dsoRD.payload.name; + route.data = this.createData(title, metadata, metadataField, name, metadataTranslated, value); + return true; + }) + ); + } else { + route.data = this.createData(title, metadata, metadataField, '', metadataTranslated, value); + return observableOf(true); + } + } + + private createData(title, metadata, metadataField, collection, field, value) { + return { + title: title, + metadata: metadata, + metadataField: metadataField, + collection: collection, + field: field, + value: hasValue(value) ? `"${value}"` : '' + } + } +} diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts index 630a7c0db5..9ba15ecfe9 100644 --- a/src/app/+browse-by/browse-by-routing.module.ts +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -1,13 +1,16 @@ import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; -import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; +import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; +import { BrowseByGuard } from './browse-by-guard'; @NgModule({ imports: [ RouterModule.forChild([ - { path: 'title', component: BrowseByTitlePageComponent }, - { path: 'author', component: BrowseByAuthorPageComponent } + { path: 'title', component: BrowseByTitlePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'title', title: 'browse.title' } }, + { path: 'dateissued', component: BrowseByDatePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'dateissued', metadataField: 'dc.date.issued', title: 'browse.title' } }, + { path: ':metadata', component: BrowseByMetadataPageComponent, canActivate: [BrowseByGuard], data: { title: 'browse.title' } } ]) ] }) diff --git a/src/app/+browse-by/browse-by.module.ts b/src/app/+browse-by/browse-by.module.ts index 51843a13d8..30d4617c16 100644 --- a/src/app/+browse-by/browse-by.module.ts +++ b/src/app/+browse-by/browse-by.module.ts @@ -4,8 +4,10 @@ import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-ti import { ItemDataService } from '../core/data/item-data.service'; import { SharedModule } from '../shared/shared.module'; import { BrowseByRoutingModule } from './browse-by-routing.module'; -import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; import { BrowseService } from '../core/browse/browse.service'; +import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; +import { BrowseByGuard } from './browse-by-guard'; @NgModule({ imports: [ @@ -15,11 +17,13 @@ import { BrowseService } from '../core/browse/browse.service'; ], declarations: [ BrowseByTitlePageComponent, - BrowseByAuthorPageComponent + BrowseByMetadataPageComponent, + BrowseByDatePageComponent ], providers: [ ItemDataService, - BrowseService + BrowseService, + BrowseByGuard ] }) export class BrowseByModule { diff --git a/src/app/+collection-page/collection-form/collection-form.component.ts b/src/app/+collection-page/collection-form/collection-form.component.ts new file mode 100644 index 0000000000..22f2f1271d --- /dev/null +++ b/src/app/+collection-page/collection-form/collection-form.component.ts @@ -0,0 +1,71 @@ +import { Component, Input } from '@angular/core'; +import { + DynamicInputModel, + DynamicTextAreaModel +} from '@ng-dynamic-forms/core'; +import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; +import { ResourceType } from '../../core/shared/resource-type'; +import { Collection } from '../../core/shared/collection.model'; +import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component'; + +/** + * Form used for creating and editing collections + */ +@Component({ + selector: 'ds-collection-form', + styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'], + templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html' +}) +export class CollectionFormComponent extends ComColFormComponent { + /** + * @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited + */ + @Input() dso: Collection = new Collection(); + + /** + * @type {ResourceType.Collection} This is a collection-type form + */ + protected type = ResourceType.Collection; + + /** + * The dynamic form fields used for creating/editing a collection + * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + */ + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'title', + name: 'dc.title', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'Please enter a name for this title' + }, + }), + new DynamicTextAreaModel({ + id: 'description', + name: 'dc.description', + }), + new DynamicTextAreaModel({ + id: 'abstract', + name: 'dc.description.abstract', + }), + new DynamicTextAreaModel({ + id: 'rights', + name: 'dc.rights', + }), + new DynamicTextAreaModel({ + id: 'tableofcontents', + name: 'dc.description.tableofcontents', + }), + new DynamicTextAreaModel({ + id: 'license', + name: 'dc.rights.license', + }), + new DynamicTextAreaModel({ + id: 'provenance', + name: 'dc.description.provenance', + }), + ]; +} diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index f04d40e234..ae5d04714a 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -3,12 +3,58 @@ import { RouterModule } from '@angular/router'; import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageResolver } from './collection-page.resolver'; -import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; +import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component'; +import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; +import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getCollectionModulePath } from '../app-routing.module'; +import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; + +export const COLLECTION_PARENT_PARAMETER = 'parent'; + +export function getCollectionPageRoute(collectionId: string) { + return new URLCombiner(getCollectionModulePath(), collectionId).toString(); +} + +export function getCollectionEditPath(id: string) { + return new URLCombiner(getCollectionModulePath(), COLLECTION_EDIT_PATH.replace(/:id/, id)).toString() +} + +export function getCollectionCreatePath() { + return new URLCombiner(getCollectionModulePath(), COLLECTION_CREATE_PATH).toString() +} + +const COLLECTION_CREATE_PATH = 'create'; +const COLLECTION_EDIT_PATH = ':id/edit'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: COLLECTION_CREATE_PATH, + component: CreateCollectionPageComponent, + canActivate: [AuthenticatedGuard, CreateCollectionPageGuard] + }, + { + path: COLLECTION_EDIT_PATH, + pathMatch: 'full', + component: EditCollectionPageComponent, + canActivate: [AuthenticatedGuard], + resolve: { + dso: CollectionPageResolver + } + }, + { + path: ':id/delete', + pathMatch: 'full', + component: DeleteCollectionPageComponent, + canActivate: [AuthenticatedGuard], + resolve: { + dso: CollectionPageResolver + } + }, { path: ':id', component: CollectionPageComponent, @@ -30,6 +76,7 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; ], providers: [ CollectionPageResolver, + CreateCollectionPageGuard ] }) export class CollectionPageRoutingModule { diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index a233163070..91239de17c 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -1,56 +1,62 @@
-
-
-
- - - - - - - - - - - - - - - - - - -
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ +
+

{{'collection.page.browse.recent.head' | translate}}

+ + +
+ + +
+
+ +
- - -
- -
-

{{'collection.page.browse.recent.head' | translate}}

- - -
- - -
-
diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index b76c0a7520..41afbf2115 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,6 +1,9 @@ -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { Observable, Subscription } from 'rxjs'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject, of as observableOf, Observable, Subject } from 'rxjs'; +import { filter, flatMap, map, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchService } from '../+search-page/search-service/search.service'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { PaginatedList } from '../core/data/paginated-list'; @@ -10,16 +13,17 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { Bitstream } from '../core/shared/bitstream.model'; import { Collection } from '../core/shared/collection.model'; +import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; import { Item } from '../core/shared/item.model'; +import { + getSucceededRemoteData, + redirectToPageNotFoundOn404, + toDSpaceObjectListRD +} from '../core/shared/operators'; import { fadeIn, fadeInOut } from '../shared/animations/fade'; -import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../shared/empty.util'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { filter, flatMap, map, tap } from 'rxjs/operators'; -import { SearchService } from '../+search-page/search-service/search.service'; -import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; -import { toDSpaceObjectListRD } from '../core/shared/operators'; -import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; @Component({ selector: 'ds-collection-page', @@ -31,20 +35,23 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; fadeInOut ] }) -export class CollectionPageComponent implements OnInit, OnDestroy { +export class CollectionPageComponent implements OnInit { collectionRD$: Observable>; itemRD$: Observable>>; logoRD$: Observable>; paginationConfig: PaginationComponentOptions; sortConfig: SortOptions; - private subs: Subscription[] = []; - private collectionId: string; + private paginationChanges$: Subject<{ + paginationConfig: PaginationComponentOptions, + sortConfig: SortOptions + }>; constructor( private collectionDataService: CollectionDataService, private searchService: SearchService, private metadata: MetadataService, - private route: ActivatedRoute + private route: ActivatedRoute, + private router: Router ) { this.paginationConfig = new PaginationComponentOptions(); this.paginationConfig.id = 'collection-page-pagination'; @@ -55,42 +62,43 @@ export class CollectionPageComponent implements OnInit, OnDestroy { ngOnInit(): void { this.collectionRD$ = this.route.data.pipe( - map((data) => data.collection) + map((data) => data.collection as RemoteData), + redirectToPageNotFoundOn404(this.router), + take(1) ); this.logoRD$ = this.collectionRD$.pipe( map((rd: RemoteData) => rd.payload), filter((collection: Collection) => hasValue(collection)), flatMap((collection: Collection) => collection.logo) ); - this.subs.push( - this.route.queryParams.subscribe((params) => { - this.metadata.processRemoteData(this.collectionRD$); - const page = +params.page || this.paginationConfig.currentPage; - const pageSize = +params.pageSize || this.paginationConfig.pageSize; - const pagination = Object.assign({}, - this.paginationConfig, - { currentPage: page, pageSize: pageSize } - ); - this.updatePage({ - pagination: pagination, - sort: this.sortConfig - }); - })); - } + this.paginationChanges$ = new BehaviorSubject({ + paginationConfig: this.paginationConfig, + sortConfig: this.sortConfig + }); - updatePage(searchOptions) { - this.itemRD$ = this.searchService.search( - new PaginatedSearchOptions({ - scope: this.collectionId, - pagination: searchOptions.pagination, - sort: searchOptions.sort, - dsoType: DSpaceObjectType.ITEM - })).pipe(toDSpaceObjectListRD()) as Observable>>; - } + this.itemRD$ = this.paginationChanges$.pipe( + switchMap((dto) => this.collectionRD$.pipe( + getSucceededRemoteData(), + map((rd) => rd.payload.id), + switchMap((id: string) => { + return this.searchService.search( + new PaginatedSearchOptions({ + scope: id, + pagination: dto.paginationConfig, + sort: dto.sortConfig, + dsoType: DSpaceObjectType.ITEM + })).pipe(toDSpaceObjectListRD()) as Observable>> + }), + startWith(undefined) // Make sure switching pages shows loading component + ) + ) + ); - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + this.route.queryParams.pipe(take(1)).subscribe((params) => { + this.metadata.processRemoteData(this.collectionRD$); + this.onPaginationChange(params); + }) } isNotEmpty(object: any) { @@ -98,15 +106,14 @@ export class CollectionPageComponent implements OnInit, OnDestroy { } onPaginationChange(event) { - this.updatePage({ - pagination: { - currentPage: event.page, - pageSize: event.pageSize - }, - sort: { - field: event.sortField, - direction: event.sortDirection - } - }) + this.paginationConfig.currentPage = +event.page || this.paginationConfig.currentPage; + this.paginationConfig.pageSize = +event.pageSize || this.paginationConfig.pageSize; + this.sortConfig.direction = event.sortDirection || this.sortConfig.direction; + this.sortConfig.field = event.sortField || this.sortConfig.field; + + this.paginationChanges$.next({ + paginationConfig: this.paginationConfig, + sortConfig: this.sortConfig + }); } } diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts index 79efea46c0..86afb37170 100644 --- a/src/app/+collection-page/collection-page.module.ts +++ b/src/app/+collection-page/collection-page.module.ts @@ -5,19 +5,29 @@ import { SharedModule } from '../shared/shared.module'; import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageRoutingModule } from './collection-page-routing.module'; -import { SearchPageModule } from '../+search-page/search-page.module'; +import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; +import { CollectionFormComponent } from './collection-form/collection-form.component'; +import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component'; +import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; +import { SearchService } from '../+search-page/search-service/search.service'; import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; @NgModule({ imports: [ CommonModule, SharedModule, - SearchPageModule, CollectionPageRoutingModule ], declarations: [ CollectionPageComponent, + CreateCollectionPageComponent, + EditCollectionPageComponent, + DeleteCollectionPageComponent, + CollectionFormComponent, CollectionItemMapperComponent + ], + providers: [ + SearchService ] }) export class CollectionPageModule { diff --git a/src/app/+collection-page/collection-page.resolver.spec.ts b/src/app/+collection-page/collection-page.resolver.spec.ts new file mode 100644 index 0000000000..5034b8d369 --- /dev/null +++ b/src/app/+collection-page/collection-page.resolver.spec.ts @@ -0,0 +1,28 @@ +import { first } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; +import { CollectionPageResolver } from './collection-page.resolver'; + +describe('CollectionPageResolver', () => { + describe('resolve', () => { + let resolver: CollectionPageResolver; + let collectionService: any; + const uuid = '1234-65487-12354-1235'; + + beforeEach(() => { + collectionService = { + findById: (id: string) => observableOf({ payload: { id }, hasSucceeded: true }) + }; + resolver = new CollectionPageResolver(collectionService); + }); + + it('should resolve a collection with the correct id', () => { + resolver.resolve({ params: { id: uuid } } as any, undefined) + .pipe(first()) + .subscribe( + (resolved) => { + expect(resolved.payload.id).toEqual(uuid); + } + ); + }); + }); +}); diff --git a/src/app/+collection-page/collection-page.resolver.ts b/src/app/+collection-page/collection-page.resolver.ts index d4835e2e14..8c6e3ad8a6 100644 --- a/src/app/+collection-page/collection-page.resolver.ts +++ b/src/app/+collection-page/collection-page.resolver.ts @@ -4,7 +4,8 @@ import { Collection } from '../core/shared/collection.model'; import { Observable } from 'rxjs'; import { CollectionDataService } from '../core/data/collection-data.service'; import { RemoteData } from '../core/data/remote-data'; -import { getSucceededRemoteData } from '../core/shared/operators'; +import { find } from 'rxjs/operators'; +import { hasValue } from '../shared/empty.util'; /** * This class represents a resolver that requests a specific collection before the route is activated @@ -18,11 +19,12 @@ export class CollectionPageResolver implements Resolve> { * Method for resolving a collection based on the parameters in the current route * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found collection based on the parameters in the current route + * @returns Observable<> Emits the found collection based on the parameters in the current route, + * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { return this.collectionService.findById(route.params.id).pipe( - getSucceededRemoteData() + find((RD) => hasValue(RD.error) || RD.hasSucceeded), ); } } diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.html b/src/app/+collection-page/create-collection-page/create-collection-page.component.html new file mode 100644 index 0000000000..b3f4361bc6 --- /dev/null +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.html @@ -0,0 +1,8 @@ +
+
+
+

{{'collection.create.sub-head' | translate:{ parent: (parentRD$| async)?.payload.name } }}

+
+
+ +
diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.scss b/src/app/+collection-page/create-collection-page/create-collection-page.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.spec.ts b/src/app/+collection-page/create-collection-page/create-collection-page.component.spec.ts new file mode 100644 index 0000000000..29350a83e0 --- /dev/null +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.spec.ts @@ -0,0 +1,46 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { RouteService } from '../../shared/services/route.service'; +import { SharedModule } from '../../shared/shared.module'; +import { CollectionDataService } from '../../core/data/collection-data.service'; +import { of as observableOf } from 'rxjs'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { CreateCollectionPageComponent } from './create-collection-page.component'; + +describe('CreateCollectionPageComponent', () => { + let comp: CreateCollectionPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [CreateCollectionPageComponent], + providers: [ + { provide: CollectionDataService, useValue: {} }, + { + provide: CommunityDataService, + useValue: { findById: () => observableOf({ payload: { name: 'test' } }) } + }, + { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, + { provide: Router, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CreateCollectionPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/collections/'); + }) + }); +}); diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts new file mode 100644 index 0000000000..94229b4932 --- /dev/null +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts @@ -0,0 +1,28 @@ +import { Component } from '@angular/core'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RouteService } from '../../shared/services/route.service'; +import { Router } from '@angular/router'; +import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; +import { Collection } from '../../core/shared/collection.model'; +import { CollectionDataService } from '../../core/data/collection-data.service'; + +/** + * Component that represents the page where a user can create a new Collection + */ +@Component({ + selector: 'ds-create-collection', + styleUrls: ['./create-collection-page.component.scss'], + templateUrl: './create-collection-page.component.html' +}) +export class CreateCollectionPageComponent extends CreateComColPageComponent { + protected frontendURL = '/collections/'; + + public constructor( + protected communityDataService: CommunityDataService, + protected collectionDataService: CollectionDataService, + protected routeService: RouteService, + protected router: Router + ) { + super(collectionDataService, communityDataService, routeService, router); + } +} diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.guard.spec.ts b/src/app/+collection-page/create-collection-page/create-collection-page.guard.spec.ts new file mode 100644 index 0000000000..5d21ae36b3 --- /dev/null +++ b/src/app/+collection-page/create-collection-page/create-collection-page.guard.spec.ts @@ -0,0 +1,67 @@ +import { CreateCollectionPageGuard } from './create-collection-page.guard'; +import { MockRouter } from '../../shared/mocks/mock-router'; +import { RemoteData } from '../../core/data/remote-data'; +import { Community } from '../../core/shared/community.model'; +import { of as observableOf } from 'rxjs'; +import { first } from 'rxjs/operators'; + +describe('CreateCollectionPageGuard', () => { + describe('canActivate', () => { + let guard: CreateCollectionPageGuard; + let router; + let communityDataServiceStub: any; + + beforeEach(() => { + communityDataServiceStub = { + findById: (id: string) => { + if (id === 'valid-id') { + return observableOf(new RemoteData(false, false, true, null, new Community())); + } else if (id === 'invalid-id') { + return observableOf(new RemoteData(false, false, true, null, undefined)); + } else if (id === 'error-id') { + return observableOf(new RemoteData(false, false, false, null, new Community())); + } + } + }; + router = new MockRouter(); + + guard = new CreateCollectionPageGuard(router, communityDataServiceStub); + }); + + it('should return true when the parent ID resolves to a community', () => { + guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(true) + ); + }); + + it('should return false when no parent ID has been provided', () => { + guard.canActivate({ queryParams: { } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(false) + ); + }); + + it('should return false when the parent ID does not resolve to a community', () => { + guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(false) + ); + }); + + it('should return false when the parent ID resolves to an error response', () => { + guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(false) + ); + }); + }); +}); diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.guard.ts b/src/app/+collection-page/create-collection-page/create-collection-page.guard.ts new file mode 100644 index 0000000000..4cd842e926 --- /dev/null +++ b/src/app/+collection-page/create-collection-page/create-collection-page.guard.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; + +import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { Community } from '../../core/shared/community.model'; +import { getFinishedRemoteData } from '../../core/shared/operators'; +import { map, tap } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; + +/** + * Prevent creation of a collection without a parent community provided + * @class CreateCollectionPageGuard + */ +@Injectable() +export class CreateCollectionPageGuard implements CanActivate { + public constructor(private router: Router, private communityService: CommunityDataService) { + } + + /** + * True when either a parent ID query parameter has been provided and the parent ID resolves to a valid parent community + * Reroutes to a 404 page when the page cannot be activated + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + const parentID = route.queryParams.parent; + if (hasNoValue(parentID)) { + this.router.navigate(['/404']); + return observableOf(false); + } + const parent: Observable> = this.communityService.findById(parentID) + .pipe( + getFinishedRemoteData(), + ); + + return parent.pipe( + map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), + tap((isValid: boolean) => { + if (!isValid) { + this.router.navigate(['/404']); + } + }) + ); + } +} diff --git a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.html b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.html new file mode 100644 index 0000000000..cfd09f2bbd --- /dev/null +++ b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.html @@ -0,0 +1,19 @@ +
+
+ +
+ +

{{ 'community.delete.text' | translate:{ dso: dso.name } }}

+ + +
+
+ +
+ +
diff --git a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.scss b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.spec.ts b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.spec.ts new file mode 100644 index 0000000000..d64c1d1915 --- /dev/null +++ b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.spec.ts @@ -0,0 +1,41 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { SharedModule } from '../../shared/shared.module'; +import { of as observableOf } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DeleteCollectionPageComponent } from './delete-collection-page.component'; +import { CollectionDataService } from '../../core/data/collection-data.service'; + +describe('DeleteCollectionPageComponent', () => { + let comp: DeleteCollectionPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [DeleteCollectionPageComponent], + providers: [ + { provide: CollectionDataService, useValue: {} }, + { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, + { provide: NotificationsService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DeleteCollectionPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/collections/'); + }) + }); +}); diff --git a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts new file mode 100644 index 0000000000..5f2bd89942 --- /dev/null +++ b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { CollectionDataService } from '../../core/data/collection-data.service'; +import { Collection } from '../../core/shared/collection.model'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Component that represents the page where a user can delete an existing Collection + */ +@Component({ + selector: 'ds-delete-collection', + styleUrls: ['./delete-collection-page.component.scss'], + templateUrl: './delete-collection-page.component.html' +}) +export class DeleteCollectionPageComponent extends DeleteComColPageComponent { + protected frontendURL = '/collections/'; + + public constructor( + protected dsoDataService: CollectionDataService, + protected router: Router, + protected route: ActivatedRoute, + protected notifications: NotificationsService, + protected translate: TranslateService + ) { + super(dsoDataService, router, route, notifications, translate); + } +} diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.html b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.html new file mode 100644 index 0000000000..c389c681ce --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.html @@ -0,0 +1,11 @@ +
+
+
+ + + {{'collection.edit.delete' + | translate}} +
+
+
diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.scss b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.spec.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.spec.ts new file mode 100644 index 0000000000..193cb293e4 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; +import { SharedModule } from '../../shared/shared.module'; +import { CollectionDataService } from '../../core/data/collection-data.service'; +import { of as observableOf } from 'rxjs'; + +describe('EditCollectionPageComponent', () => { + let comp: EditCollectionPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [EditCollectionPageComponent], + providers: [ + { provide: CollectionDataService, useValue: {} }, + { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditCollectionPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/collections/'); + }) + }); +}); diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts new file mode 100644 index 0000000000..ba70bd26c6 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; +import { Collection } from '../../core/shared/collection.model'; +import { CollectionDataService } from '../../core/data/collection-data.service'; + +/** + * Component that represents the page where a user can edit an existing Collection + */ +@Component({ + selector: 'ds-edit-collection', + styleUrls: ['./edit-collection-page.component.scss'], + templateUrl: './edit-collection-page.component.html' +}) +export class EditCollectionPageComponent extends EditComColPageComponent { + protected frontendURL = '/collections/'; + + public constructor( + protected collectionDataService: CollectionDataService, + protected router: Router, + protected route: ActivatedRoute + ) { + super(collectionDataService, router, route); + } +} diff --git a/src/app/+community-page/community-form/community-form.component.ts b/src/app/+community-page/community-form/community-form.component.ts new file mode 100644 index 0000000000..9ae6f0955d --- /dev/null +++ b/src/app/+community-page/community-form/community-form.component.ts @@ -0,0 +1,60 @@ +import { Component, Input } from '@angular/core'; +import { DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; +import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; +import { Community } from '../../core/shared/community.model'; +import { ResourceType } from '../../core/shared/resource-type'; +import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component'; + +/** + * Form used for creating and editing communities + */ +@Component({ + selector: 'ds-community-form', + styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'], + templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html' +}) +export class CommunityFormComponent extends ComColFormComponent { + /** + * @type {Community} A new community when a community is being created, an existing Input community when a community is being edited + */ + @Input() dso: Community = new Community(); + + /** + * @type {ResourceType.Community} This is a community-type form + */ + protected type = ResourceType.Community; + + /** + * The dynamic form fields used for creating/editing a community + * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + */ + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'title', + name: 'dc.title', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'Please enter a name for this title' + }, + }), + new DynamicTextAreaModel({ + id: 'description', + name: 'dc.description', + }), + new DynamicTextAreaModel({ + id: 'abstract', + name: 'dc.description.abstract', + }), + new DynamicTextAreaModel({ + id: 'rights', + name: 'dc.rights', + }), + new DynamicTextAreaModel({ + id: 'tableofcontents', + name: 'dc.description.tableofcontents', + }), + ]; +} diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index 4cc927d341..cecd17ec10 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -3,10 +3,57 @@ import { RouterModule } from '@angular/router'; import { CommunityPageComponent } from './community-page.component'; import { CommunityPageResolver } from './community-page.resolver'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component'; +import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard'; +import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getCommunityModulePath } from '../app-routing.module'; + +export const COMMUNITY_PARENT_PARAMETER = 'parent'; + +export function getCommunityPageRoute(communityId: string) { + return new URLCombiner(getCommunityModulePath(), communityId).toString(); +} + +export function getCommunityEditPath(id: string) { + return new URLCombiner(getCommunityModulePath(), COMMUNITY_EDIT_PATH.replace(/:id/, id)).toString() +} + +export function getCommunityCreatePath() { + return new URLCombiner(getCommunityModulePath(), COMMUNITY_CREATE_PATH).toString() +} + +const COMMUNITY_CREATE_PATH = 'create'; +const COMMUNITY_EDIT_PATH = ':id/edit'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: COMMUNITY_CREATE_PATH, + component: CreateCommunityPageComponent, + canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] + }, + { + path: COMMUNITY_EDIT_PATH, + pathMatch: 'full', + component: EditCommunityPageComponent, + canActivate: [AuthenticatedGuard], + resolve: { + dso: CommunityPageResolver + } + }, + { + path: ':id/delete', + pathMatch: 'full', + component: DeleteCommunityPageComponent, + canActivate: [AuthenticatedGuard], + resolve: { + dso: CommunityPageResolver + } + }, { path: ':id', component: CommunityPageComponent, @@ -19,6 +66,7 @@ import { CommunityPageResolver } from './community-page.resolver'; ], providers: [ CommunityPageResolver, + CreateCommunityPageGuard ] }) export class CommunityPageRoutingModule { diff --git a/src/app/+community-page/community-page.component.html b/src/app/+community-page/community-page.component.html index 844250f58f..e429d224f2 100644 --- a/src/app/+community-page/community-page.component.html +++ b/src/app/+community-page/community-page.component.html @@ -3,6 +3,8 @@
+ +
+ - + diff --git a/src/app/+community-page/community-page.component.ts b/src/app/+community-page/community-page.component.ts index 09b3a3b62b..f337d70250 100644 --- a/src/app/+community-page/community-page.component.ts +++ b/src/app/+community-page/community-page.component.ts @@ -1,6 +1,6 @@ -import { mergeMap, filter, map, first, tap } from 'rxjs/operators'; +import { mergeMap, filter, map } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Subscription, Observable } from 'rxjs'; import { CommunityDataService } from '../core/data/community-data.service'; @@ -13,6 +13,7 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; +import { redirectToPageNotFoundOn404 } from '../core/shared/operators'; @Component({ selector: 'ds-community-page', @@ -21,30 +22,37 @@ import { hasValue } from '../shared/empty.util'; changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) -export class CommunityPageComponent implements OnInit, OnDestroy { +/** + * This component represents a detail page for a single community + */ +export class CommunityPageComponent implements OnInit { + /** + * The community displayed on this page + */ communityRD$: Observable>; + + /** + * The logo of this community + */ logoRD$: Observable>; - - private subs: Subscription[] = []; - constructor( private communityDataService: CommunityDataService, private metadata: MetadataService, - private route: ActivatedRoute + private route: ActivatedRoute, + private router: Router ) { } ngOnInit(): void { - this.communityRD$ = this.route.data.pipe(map((data) => data.community)); + this.communityRD$ = this.route.data.pipe( + map((data) => data.community as RemoteData), + redirectToPageNotFoundOn404(this.router) + ); this.logoRD$ = this.communityRD$.pipe( map((rd: RemoteData) => rd.payload), filter((community: Community) => hasValue(community)), mergeMap((community: Community) => community.logo)); } - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - } - } diff --git a/src/app/+community-page/community-page.module.ts b/src/app/+community-page/community-page.module.ts index d7f97755c2..6d63cadcc8 100644 --- a/src/app/+community-page/community-page.module.ts +++ b/src/app/+community-page/community-page.module.ts @@ -7,6 +7,10 @@ import { CommunityPageComponent } from './community-page.component'; import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; import { CommunityPageRoutingModule } from './community-page-routing.module'; import {CommunityPageSubCommunityListComponent} from './sub-community-list/community-page-sub-community-list.component'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { CommunityFormComponent } from './community-form/community-form.component'; +import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component'; +import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; @NgModule({ imports: [ @@ -18,8 +22,13 @@ import {CommunityPageSubCommunityListComponent} from './sub-community-list/commu CommunityPageComponent, CommunityPageSubCollectionListComponent, CommunityPageSubCommunityListComponent, + CreateCommunityPageComponent, + EditCommunityPageComponent, + DeleteCommunityPageComponent, + CommunityFormComponent ] }) + export class CommunityPageModule { } diff --git a/src/app/+community-page/community-page.resolver.ts b/src/app/+community-page/community-page.resolver.ts index a32fe78bc5..ffa66fa123 100644 --- a/src/app/+community-page/community-page.resolver.ts +++ b/src/app/+community-page/community-page.resolver.ts @@ -2,9 +2,10 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; import { RemoteData } from '../core/data/remote-data'; -import { getSucceededRemoteData } from '../core/shared/operators'; import { Community } from '../core/shared/community.model'; import { CommunityDataService } from '../core/data/community-data.service'; +import { find } from 'rxjs/operators'; +import { hasValue } from '../shared/empty.util'; /** * This class represents a resolver that requests a specific community before the route is activated @@ -18,11 +19,12 @@ export class CommunityPageResolver implements Resolve> { * Method for resolving a community based on the parameters in the current route * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found community based on the parameters in the current route + * @returns Observable<> Emits the found community based on the parameters in the current route, + * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { return this.communityService.findById(route.params.id).pipe( - getSucceededRemoteData() + find((RD) => hasValue(RD.error) || RD.hasSucceeded) ); } } diff --git a/src/app/+community-page/create-community-page/create-community-page.component.html b/src/app/+community-page/create-community-page/create-community-page.component.html new file mode 100644 index 0000000000..55a080d2a1 --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.component.html @@ -0,0 +1,11 @@ +
+
+
+ + +

{{ 'community.create.sub-head' | translate:{ parent: parent.name } }}

+
+
+
+ +
diff --git a/src/app/+community-page/create-community-page/create-community-page.component.scss b/src/app/+community-page/create-community-page/create-community-page.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+community-page/create-community-page/create-community-page.component.spec.ts b/src/app/+community-page/create-community-page/create-community-page.component.spec.ts new file mode 100644 index 0000000000..dba15dbe88 --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { RouteService } from '../../shared/services/route.service'; +import { SharedModule } from '../../shared/shared.module'; +import { CollectionDataService } from '../../core/data/collection-data.service'; +import { of as observableOf } from 'rxjs'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { CreateCommunityPageComponent } from './create-community-page.component'; + +describe('CreateCommunityPageComponent', () => { + let comp: CreateCommunityPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [CreateCommunityPageComponent], + providers: [ + { provide: CommunityDataService, useValue: { findById: () => observableOf({}) } }, + { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, + { provide: Router, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CreateCommunityPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/communities/'); + }) + }); +}); diff --git a/src/app/+community-page/create-community-page/create-community-page.component.ts b/src/app/+community-page/create-community-page/create-community-page.component.ts new file mode 100644 index 0000000000..828d8338af --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { Community } from '../../core/shared/community.model'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RouteService } from '../../shared/services/route.service'; +import { Router } from '@angular/router'; +import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; + +/** + * Component that represents the page where a user can create a new Community + */ +@Component({ + selector: 'ds-create-community', + styleUrls: ['./create-community-page.component.scss'], + templateUrl: './create-community-page.component.html' +}) +export class CreateCommunityPageComponent extends CreateComColPageComponent { + protected frontendURL = '/communities/'; + + public constructor( + protected communityDataService: CommunityDataService, + protected routeService: RouteService, + protected router: Router + ) { + super(communityDataService, communityDataService, routeService, router); + } +} diff --git a/src/app/+community-page/create-community-page/create-community-page.guard.spec.ts b/src/app/+community-page/create-community-page/create-community-page.guard.spec.ts new file mode 100644 index 0000000000..0cc7232871 --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.guard.spec.ts @@ -0,0 +1,67 @@ +import { CreateCommunityPageGuard } from './create-community-page.guard'; +import { MockRouter } from '../../shared/mocks/mock-router'; +import { RemoteData } from '../../core/data/remote-data'; +import { Community } from '../../core/shared/community.model'; +import { of as observableOf } from 'rxjs'; +import { first } from 'rxjs/operators'; + +describe('CreateCommunityPageGuard', () => { + describe('canActivate', () => { + let guard: CreateCommunityPageGuard; + let router; + let communityDataServiceStub: any; + + beforeEach(() => { + communityDataServiceStub = { + findById: (id: string) => { + if (id === 'valid-id') { + return observableOf(new RemoteData(false, false, true, null, new Community())); + } else if (id === 'invalid-id') { + return observableOf(new RemoteData(false, false, true, null, undefined)); + } else if (id === 'error-id') { + return observableOf(new RemoteData(false, false, false, null, new Community())); + } + } + }; + router = new MockRouter(); + + guard = new CreateCommunityPageGuard(router, communityDataServiceStub); + }); + + it('should return true when the parent ID resolves to a community', () => { + guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(true) + ); + }); + + it('should return true when no parent ID has been provided', () => { + guard.canActivate({ queryParams: { } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(true) + ); + }); + + it('should return false when the parent ID does not resolve to a community', () => { + guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(false) + ); + }); + + it('should return false when the parent ID resolves to an error response', () => { + guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => + expect(canActivate).toEqual(false) + ); + }); + }); +}); diff --git a/src/app/+community-page/create-community-page/create-community-page.guard.ts b/src/app/+community-page/create-community-page/create-community-page.guard.ts new file mode 100644 index 0000000000..2ee5cb6064 --- /dev/null +++ b/src/app/+community-page/create-community-page/create-community-page.guard.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; + +import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { Community } from '../../core/shared/community.model'; +import { getFinishedRemoteData } from '../../core/shared/operators'; +import { map, tap } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; + +/** + * Prevent creation of a community with an invalid parent community provided + * @class CreateCommunityPageGuard + */ +@Injectable() +export class CreateCommunityPageGuard implements CanActivate { + public constructor(private router: Router, private communityService: CommunityDataService) { + } + + /** + * True when either NO parent ID query parameter has been provided, or the parent ID resolves to a valid parent community + * Reroutes to a 404 page when the page cannot be activated + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + const parentID = route.queryParams.parent; + if (hasNoValue(parentID)) { + return observableOf(true); + } + + const parent: Observable> = this.communityService.findById(parentID) + .pipe( + getFinishedRemoteData(), + ); + + return parent.pipe( + map((communityRD: RemoteData) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)), + tap((isValid: boolean) => { + if (!isValid) { + this.router.navigate(['/404']); + } + }) + ); + } +} diff --git a/src/app/+community-page/delete-community-page/delete-community-page.component.html b/src/app/+community-page/delete-community-page/delete-community-page.component.html new file mode 100644 index 0000000000..cfd09f2bbd --- /dev/null +++ b/src/app/+community-page/delete-community-page/delete-community-page.component.html @@ -0,0 +1,19 @@ +
+
+ +
+ +

{{ 'community.delete.text' | translate:{ dso: dso.name } }}

+ + +
+
+ +
+ +
diff --git a/src/app/+community-page/delete-community-page/delete-community-page.component.scss b/src/app/+community-page/delete-community-page/delete-community-page.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+community-page/delete-community-page/delete-community-page.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+community-page/delete-community-page/delete-community-page.component.spec.ts b/src/app/+community-page/delete-community-page/delete-community-page.component.spec.ts new file mode 100644 index 0000000000..f18c4fb1f1 --- /dev/null +++ b/src/app/+community-page/delete-community-page/delete-community-page.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { RouteService } from '../../shared/services/route.service'; +import { SharedModule } from '../../shared/shared.module'; +import { of as observableOf } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DeleteCommunityPageComponent } from './delete-community-page.component'; +import { CommunityDataService } from '../../core/data/community-data.service'; + +describe('DeleteCommunityPageComponent', () => { + let comp: DeleteCommunityPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [DeleteCommunityPageComponent], + providers: [ + { provide: CommunityDataService, useValue: {} }, + { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, + { provide: NotificationsService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DeleteCommunityPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/communities/'); + }) + }); +}); diff --git a/src/app/+community-page/delete-community-page/delete-community-page.component.ts b/src/app/+community-page/delete-community-page/delete-community-page.component.ts new file mode 100644 index 0000000000..9f1465a3c7 --- /dev/null +++ b/src/app/+community-page/delete-community-page/delete-community-page.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { Community } from '../../core/shared/community.model'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Component that represents the page where a user can delete an existing Community + */ +@Component({ + selector: 'ds-delete-community', + styleUrls: ['./delete-community-page.component.scss'], + templateUrl: './delete-community-page.component.html' +}) +export class DeleteCommunityPageComponent extends DeleteComColPageComponent { + protected frontendURL = '/communities/'; + + public constructor( + protected dsoDataService: CommunityDataService, + protected router: Router, + protected route: ActivatedRoute, + protected notifications: NotificationsService, + protected translate: TranslateService + ) { + super(dsoDataService, router, route, notifications, translate); + } +} diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.html b/src/app/+community-page/edit-community-page/edit-community-page.component.html new file mode 100644 index 0000000000..cedb771c14 --- /dev/null +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.html @@ -0,0 +1,12 @@ +
+
+
+ + + {{'community.edit.delete' + | translate}} +
+
+
diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.scss b/src/app/+community-page/edit-community-page/edit-community-page.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.spec.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.spec.ts new file mode 100644 index 0000000000..54f2133ce7 --- /dev/null +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { SharedModule } from '../../shared/shared.module'; +import { of as observableOf } from 'rxjs'; +import { EditCommunityPageComponent } from './edit-community-page.component'; +import { CommunityDataService } from '../../core/data/community-data.service'; + +describe('EditCommunityPageComponent', () => { + let comp: EditCommunityPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [EditCommunityPageComponent], + providers: [ + { provide: CommunityDataService, useValue: {} }, + { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditCommunityPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/communities/'); + }) + }); +}); diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.ts new file mode 100644 index 0000000000..9f49ac49dd --- /dev/null +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { Community } from '../../core/shared/community.model'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; + +/** + * Component that represents the page where a user can edit an existing Community + */ +@Component({ + selector: 'ds-edit-community', + styleUrls: ['./edit-community-page.component.scss'], + templateUrl: './edit-community-page.component.html' +}) +export class EditCommunityPageComponent extends EditComColPageComponent { + protected frontendURL = '/communities/'; + + public constructor( + protected communityDataService: CommunityDataService, + protected router: Router, + protected route: ActivatedRoute + ) { + super(communityDataService, router, route); + } +} diff --git a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index 0c985e37f9..3e6190ae6d 100644 --- a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -17,45 +17,39 @@ describe('SubCommunityList Component', () => { let fixture: ComponentFixture; const subcommunities = [Object.assign(new Community(), { - name: 'SubCommunity 1', id: '123456789-1', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'SubCommunity 1' - }] + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 1' } + ] + } }), Object.assign(new Community(), { - name: 'SubCommunity 2', id: '123456789-2', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'SubCommunity 2' - }] + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 2' } + ] + } }) ]; const emptySubCommunitiesCommunity = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }], + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' } + ] + }, subcommunities: observableOf(new RemoteData(true, true, true, undefined, new PaginatedList(new PageInfo(), []))) }); const mockCommunity = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }], + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' } + ] + }, subcommunities: observableOf(new RemoteData(true, true, true, undefined, new PaginatedList(new PageInfo(), subcommunities))) }) diff --git a/src/app/+home-page/home-news/home-news.component.html b/src/app/+home-page/home-news/home-news.component.html index 2287329b37..28e10c5804 100644 --- a/src/app/+home-page/home-news/home-news.component.html +++ b/src/app/+home-page/home-news/home-news.component.html @@ -3,16 +3,17 @@
-

Welcome to DSpace

-

DSpace is an open source software platform that enables organisations to:

+

Welcome to the DSpace 7 Preview

+

DSpace is the world leading open source repository platform that enables organisations to:

    -
  • capture and describe digital material using a submission workflow module, or a variety of programmatic ingest options +
  • easily ingest documents, audio, video, datasets and their corresponding Dublin Core metadata
  • -
  • distribute an organisation's digital assets over the web through a search and retrieval system +
  • open up this content to local and global audiences, thanks to the OAI-PMH interface and Google Scholar optimizations
  • -
  • preserve digital assets over the long term
  • +
  • issue permanent urls and trustworthy identifiers, including optional integrations with handle.net and DataCite DOI
+

Join an international community of leading institutions using DSpace.

diff --git a/src/app/+home-page/home-news/home-news.component.ts b/src/app/+home-page/home-news/home-news.component.ts index a70e95547d..cebe217623 100644 --- a/src/app/+home-page/home-news/home-news.component.ts +++ b/src/app/+home-page/home-news/home-news.component.ts @@ -5,6 +5,10 @@ import { Component } from '@angular/core'; styleUrls: ['./home-news.component.scss'], templateUrl: './home-news.component.html' }) + +/** + * Component to render the news section on the home page + */ export class HomeNewsComponent { } diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html index 6a3e20ca9d..39ba479033 100644 --- a/src/app/+home-page/home-page.component.html +++ b/src/app/+home-page/home-page.component.html @@ -1,5 +1,5 @@
- +
diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.html b/src/app/+home-page/top-level-community-list/top-level-community-list.component.html index 934bb3933c..f318a04f38 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.html +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.html @@ -1,12 +1,13 @@ - -
+ +

{{'home.top-level-communities.head' | translate}}

{{'home.top-level-communities.help' | translate}}

+ [objects]="communitiesRD$ | async" + [hideGear]="true" + (paginationChange)="onPaginationChange($event)">
diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts index 3fdb7e48a2..1115d785a3 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { Observable } from 'rxjs'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { PaginatedList } from '../../core/data/paginated-list'; @@ -9,7 +9,11 @@ import { Community } from '../../core/shared/community.model'; import { fadeInOut } from '../../shared/animations/fade'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { take } from 'rxjs/operators'; +/** + * this component renders the Top-Level Community list + */ @Component({ selector: 'ds-top-level-community-list', styleUrls: ['./top-level-community-list.component.scss'], @@ -18,9 +22,20 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c animations: [fadeInOut] }) -export class TopLevelCommunityListComponent { - communitiesRDObs: Observable>>; +export class TopLevelCommunityListComponent implements OnInit { + /** + * A list of remote data objects of all top communities + */ + communitiesRD$: BehaviorSubject>> = new BehaviorSubject>>({} as any); + + /** + * The pagination configuration + */ config: PaginationComponentOptions; + + /** + * The sorting configuration + */ sortConfig: SortOptions; constructor(private cds: CommunityDataService) { @@ -29,20 +44,34 @@ export class TopLevelCommunityListComponent { this.config.pageSize = 5; this.config.currentPage = 1; this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); - - this.updatePage({ - page: this.config.currentPage, - pageSize: this.config.pageSize, - sortField: this.sortConfig.field, - direction: this.sortConfig.direction - }); } - updatePage(data) { - this.communitiesRDObs = this.cds.findTop({ - currentPage: data.page, - elementsPerPage: data.pageSize, - sort: { field: data.sortField, direction: data.sortDirection } + ngOnInit() { + this.updatePage(); + } + + /** + * Called when one of the pagination settings is changed + * @param event The new pagination data + */ + onPaginationChange(event) { + this.config.currentPage = event.page; + this.config.pageSize = event.pageSize; + this.sortConfig.field = event.sortField; + this.sortConfig.direction = event.sortDirection; + this.updatePage(); + } + + /** + * Update the list of top communities + */ + updatePage() { + this.cds.findTop({ + currentPage: this.config.currentPage, + elementsPerPage: this.config.pageSize, + sort: { field: this.sortConfig.field, direction: this.sortConfig.direction } + }).pipe(take(1)).subscribe((results) => { + this.communitiesRD$.next(results); }); } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.html b/src/app/+item-page/edit-item-page/edit-item-page.component.html index 001b484c2c..ca1c809cd9 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.html +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.html @@ -1,36 +1,24 @@
-
-
-

{{'item.edit.head' | translate}}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+

{{'item.edit.head' | translate}}

+ +
-
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.scss b/src/app/+item-page/edit-item-page/edit-item-page.component.scss new file mode 100644 index 0000000000..f22ca8f8de --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.scss @@ -0,0 +1,5 @@ +@import '../../../styles/variables.scss'; + +.btn { + min-width: $edit-item-button-min-width; +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index d276a15005..eafc04ae0b 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -1,10 +1,12 @@ import { fadeIn, fadeInOut } from '../../shared/animations/fade'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; +import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getItemPageRoute } from '../item-page-routing.module'; @Component({ selector: 'ds-edit-item-page', @@ -25,11 +27,34 @@ export class EditItemPageComponent implements OnInit { */ itemRD$: Observable>; - constructor(private route: ActivatedRoute) { + /** + * The current page outlet string + */ + currentPage: string; + + /** + * All possible page outlet strings + */ + pages: string[]; + + constructor(private route: ActivatedRoute, private router: Router) { + this.router.events.subscribe(() => { + this.currentPage = this.route.snapshot.firstChild.routeConfig.path; + }); } ngOnInit(): void { + this.pages = this.route.routeConfig.children + .map((child: any) => child.path) + .filter((path: string) => isNotEmpty(path)); // ignore reroutes this.itemRD$ = this.route.data.pipe(map((data) => data.item)); } + /** + * Get the item page url + * @param item The item for which the url is requested + */ + getItemPage(item: Item): string { + return getItemPageRoute(item.id) + } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 7ba209345a..3f5eb12071 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -2,18 +2,21 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '../../shared/shared.module'; import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; -import { SearchPageModule } from '../../+search-page/search-page.module'; import { EditItemPageComponent } from './edit-item-page.component'; -import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemOperationComponent } from './item-operation/item-operation.component'; -import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract-simple-item-action.component'; import { ModifyItemOverviewComponent } from './modify-item-overview/modify-item-overview.component'; import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component'; +import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract-simple-item-action.component'; import { ItemPrivateComponent } from './item-private/item-private.component'; import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; +import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; +import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { SearchPageModule } from '../../+search-page/search-page.module'; +import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -36,6 +39,9 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; ItemPublicComponent, ItemDeleteComponent, ItemStatusComponent, + ItemMetadataComponent, + ItemBitstreamsComponent, + EditInPlaceFieldComponent, ItemCollectionMapperComponent ] }) diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 390095f423..6dce48793c 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -2,12 +2,15 @@ import { ItemPageResolver } from '../item-page.resolver'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { EditItemPageComponent } from './edit-item-page.component'; -import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component'; import { ItemPrivateComponent } from './item-private/item-private.component'; import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; +import { ItemStatusComponent } from './item-status/item-status.component'; +import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -26,7 +29,40 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; component: EditItemPageComponent, resolve: { item: ItemPageResolver - } + }, + children: [ + { + path: '', + redirectTo: 'status', + }, + { + path: 'status', + component: ItemStatusComponent, + data: { title: 'item.edit.tabs.status.title' } + }, + { + path: 'bitstreams', + component: ItemBitstreamsComponent, + data: { title: 'item.edit.tabs.bitstreams.title' } + }, + { + path: 'metadata', + component: ItemMetadataComponent, + data: { title: 'item.edit.tabs.metadata.title' } + }, + { + path: 'view', + /* TODO - change when view page exists */ + component: ItemBitstreamsComponent, + data: { title: 'item.edit.tabs.view.title' } + }, + { + path: 'curate', + /* TODO - change when curate page exists */ + component: ItemBitstreamsComponent, + data: { title: 'item.edit.tabs.curate.title' } + }, + ] }, { path: 'mapper', diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html new file mode 100644 index 0000000000..b80e6e0678 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss new file mode 100644 index 0000000000..88eb98509a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables.scss'; \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts new file mode 100644 index 0000000000..71f25cd5cf --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-item-bitstreams', + styleUrls: ['./item-bitstreams.component.scss'], + templateUrl: './item-bitstreams.component.html', +}) +/** + * Component for displaying an item's bitstreams edit page + */ +export class ItemBitstreamsComponent { + /* TODO implement */ +} diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts index f27dc95bac..6d435c8de8 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts @@ -1,21 +1,21 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Item} from '../../../core/shared/item.model'; -import {RouterStub} from '../../../shared/testing/router-stub'; -import {of as observableOf} from 'rxjs'; -import {RemoteData} from '../../../core/data/remote-data'; -import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; -import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; -import {RouterTestingModule} from '@angular/router/testing'; -import {TranslateModule} from '@ngx-translate/core'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; -import {By} from '@angular/platform-browser'; -import {ItemDeleteComponent} from './item-delete.component'; -import {getItemEditPath} from '../../item-page-routing.module'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemDeleteComponent } from './item-delete.component'; +import { getItemEditPath } from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models'; let comp: ItemDeleteComponent; @@ -27,8 +27,6 @@ let routerStub; let mockItemDataService: ItemDataService; let routeStub; let notificationsServiceStub; -let successfulRestResponse; -let failRestResponse; describe('ItemDeleteComponent', () => { beforeEach(async(() => { @@ -46,14 +44,12 @@ describe('ItemDeleteComponent', () => { }); mockItemDataService = jasmine.createSpyObj('mockItemDataService', { - delete: observableOf(new RestResponse(true, '200')) + delete: observableOf(true) }); routeStub = { data: observableOf({ - item: new RemoteData(false, false, true, null, { - id: 'fake-id' - }) + item: new RemoteData(false, false, true, null, mockItem) }) }; @@ -63,10 +59,10 @@ describe('ItemDeleteComponent', () => { imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [ItemDeleteComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataService}, - {provide: NotificationsService, useValue: notificationsServiceStub}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -74,9 +70,6 @@ describe('ItemDeleteComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); - fixture = TestBed.createComponent(ItemDeleteComponent); comp = fixture.componentInstance; fixture.detectChanges(); @@ -95,22 +88,21 @@ describe('ItemDeleteComponent', () => { describe('performAction', () => { it('should call delete function from the ItemDataService', () => { - spyOn(comp, 'processRestResponse'); + spyOn(comp, 'notify'); comp.performAction(); - - expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem.id); - expect(comp.processRestResponse).toHaveBeenCalled(); + expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem); + expect(comp.notify).toHaveBeenCalled(); }); }); - describe('processRestResponse', () => { + describe('notify', () => { it('should navigate to the homepage on successful deletion of the item', () => { - comp.processRestResponse(successfulRestResponse); + comp.notify(true); expect(routerStub.navigate).toHaveBeenCalledWith(['']); }); }); - describe('processRestResponse', () => { + describe('notify', () => { it('should navigate to the item edit page on failed deletion of the item', () => { - comp.processRestResponse(failRestResponse); + comp.notify(false); expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath('fake-id')]); }); }); diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts index cc09565655..2700b45475 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts @@ -1,7 +1,7 @@ -import {Component} from '@angular/core'; -import {first} from 'rxjs/operators'; -import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; -import {getItemEditPath} from '../../item-page-routing.module'; +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { getItemEditPath } from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models'; @Component({ @@ -19,20 +19,19 @@ export class ItemDeleteComponent extends AbstractSimpleItemActionComponent { * Perform the delete action to the item */ performAction() { - this.itemDataService.delete(this.item.id).pipe(first()).subscribe( - (response: RestResponse) => { - this.processRestResponse(response); + this.itemDataService.delete(this.item).pipe(first()).subscribe( + (succeeded: boolean) => { + this.notify(succeeded); } ); } /** - * Process the RestResponse retrieved from the server. * When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page * @param response */ - processRestResponse(response: RestResponse) { - if (response.isSuccessful) { + notify(succeeded: boolean) { + if (succeeded) { this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success')); this.router.navigate(['']); } else { diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html new file mode 100644 index 0000000000..e9c5de95ca --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -0,0 +1,70 @@ + + + + +
+
+ {{metadata?.value}} +
+
+ +
+
+ + +
+
+ {{metadata?.language}} +
+
+ +
+
+ + +
+ + + + +
+ \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss new file mode 100644 index 0000000000..14782326f6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss @@ -0,0 +1,14 @@ +@import '../../../../../styles/variables.scss'; +.btn[disabled] { + color: $gray-600; + border-color: $gray-600; + z-index: 0; // prevent border colors jumping on hover +} + +.metadata-field { + width: $edit-item-metadata-field-width; +} + +.language-field { + width: $edit-item-language-field-width; +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts new file mode 100644 index 0000000000..09363b9964 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -0,0 +1,432 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { EditInPlaceFieldComponent } from './edit-in-place-field.component'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { MetadataField } from '../../../../core/metadata/metadatafield.model'; +import { By } from '@angular/platform-browser'; +import { FormsModule } from '@angular/forms'; +import { SharedModule } from '../../../../shared/shared.module'; +import { getTestScheduler } from 'jasmine-marbles'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; +import { TestScheduler } from 'rxjs/testing'; +import { MetadataSchema } from '../../../../core/metadata/metadataschema.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { TranslateModule } from '@ngx-translate/core'; +import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; + +let comp: EditInPlaceFieldComponent; +let fixture: ComponentFixture; +let de: DebugElement; +let el: HTMLElement; +let metadataFieldService; +let objectUpdatesService; +let paginatedMetadataFields; +const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }) +const mdField1 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'contributor', + qualifier: 'author' +}); +const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' }); +const mdField3 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'description', + qualifier: 'abstract' +}); + +const metadatum = Object.assign(new MetadatumViewModel(), { + key: 'dc.description.abstract', + value: 'Example abstract', + language: 'en' +}); + +const url = 'http://test-url.com/test-url'; +const fieldUpdate = { + field: metadatum, + changeType: undefined +}; +let scheduler: TestScheduler; + +describe('EditInPlaceFieldComponent', () => { + + beforeEach(async(() => { + scheduler = getTestScheduler(); + + paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); + + metadataFieldService = jasmine.createSpyObj({ + queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)), + }); + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + saveChangeFieldUpdate: {}, + saveRemoveFieldUpdate: {}, + setEditableFieldUpdate: {}, + setValidFieldUpdate: {}, + removeSingleFieldUpdate: {}, + isEditable: observableOf(false), // should always return something --> its in ngOnInit + isValid: observableOf(true) // should always return something --> its in ngOnInit + } + ); + + TestBed.configureTestingModule({ + imports: [FormsModule, SharedModule, TranslateModule.forRoot()], + declarations: [EditInPlaceFieldComponent], + providers: [ + { provide: RegistryService, useValue: metadataFieldService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditInPlaceFieldComponent); + comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance + de = fixture.debugElement; + el = de.nativeElement; + + comp.url = url; + comp.fieldUpdate = fieldUpdate; + comp.metadata = metadatum; + + fixture.detectChanges(); + }); + + describe('update', () => { + beforeEach(() => { + comp.update(); + }); + + it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum); + }); + }); + + describe('setEditable', () => { + const editable = false; + beforeEach(() => { + comp.setEditable(editable); + }); + + it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => { + expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid, editable); + }); + }); + + describe('editable is true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + fixture.detectChanges(); + }); + it('the div should contain input fields or textareas', () => { + const inputField = de.queryAll(By.css('input')); + const textAreas = de.queryAll(By.css('textarea')); + expect(inputField.length + textAreas.length).toBeGreaterThan(0); + }); + }); + + describe('editable is false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + fixture.detectChanges(); + }); + it('the div should contain no input fields or textareas', () => { + const inputField = de.queryAll(By.css('input')); + const textAreas = de.queryAll(By.css('textarea')); + expect(inputField.length + textAreas.length).toBe(0); + }); + }); + + describe('isValid is true', () => { + beforeEach(() => { + comp.valid = observableOf(true); + fixture.detectChanges(); + }); + it('the div should not contain an error message', () => { + const errorMessages = de.queryAll(By.css('small.text-danger')); + expect(errorMessages.length).toBe(0); + + }); + }); + + describe('isValid is false', () => { + beforeEach(() => { + comp.valid = observableOf(false); + fixture.detectChanges(); + }); + it('the div should contain no input fields or textareas', () => { + const errorMessages = de.queryAll(By.css('small.text-danger')); + expect(errorMessages.length).toBeGreaterThan(0); + + }); + }); + + describe('remove', () => { + beforeEach(() => { + comp.remove(); + }); + + it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum); + }); + }); + + describe('removeChangesFromField', () => { + beforeEach(() => { + comp.removeChangesFromField(); + }); + + it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid); + }); + }); + + describe('findMetadataFieldSuggestions', () => { + const query = 'query string'; + + const metadataFieldSuggestions: InputSuggestion[] = + [ + { displayValue: mdField1.toString().split('.').join('.​'), value: mdField1.toString() }, + { displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() }, + { displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() } + ]; + + beforeEach(() => { + comp.findMetadataFieldSuggestions(query); + + }); + + it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => { + + expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query); + }); + + it('it should set metadataFieldSuggestions to the right value', () => { + const expected = 'a'; + scheduler.expectObservable(comp.metadataFieldSuggestions).toBe(expected, { a: metadataFieldSuggestions }); + }); + }); + + describe('canSetEditable', () => { + describe('when editable is currently true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + }); + + it('canSetEditable should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false }); + }); + }); + + describe('when editable is currently false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + }); + + describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + it('canSetEditable should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.REMOVE; + }); + it('canSetEditable should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false }); + }); + }) + }); + }); + + describe('canSetUneditable', () => { + describe('when editable is currently true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + }); + + it('canSetUneditable should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: true }); + }); + }); + + describe('when editable is currently false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + }); + + it('canSetUneditable should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: false }); + }); + }); + }); + + describe('when canSetEditable emits true', () => { + beforeEach(() => { + comp.editable = observableOf(false); + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(false); + }); + }); + + describe('when canSetEditable emits false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(true); + }); + }); + + describe('when canSetUneditable emits true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(false); + }); + }); + + describe('when canSetUneditable emits false', () => { + beforeEach(() => { + comp.editable = observableOf(true); + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(true); + }); + }); + + describe('when canRemove emits true', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(false); + }); + }); + + describe('when canRemove emits false', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(true); + }); + }); + + describe('when canUndo emits true', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(false); + }); + }); + + describe('when canUndo emits false', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(true); + }); + }); + + describe('canRemove', () => { + describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.UPDATE; + }); + it('canRemove should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + it('canRemove should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false }); + }); + }) + }); + + describe('canUndo', () => { + + describe('when editable is currently true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + comp.fieldUpdate.changeType = undefined; + fixture.detectChanges(); + }); + it('canUndo should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); + }); + }); + + describe('when editable is currently false', () => { + describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + + it('canUndo should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently undefined', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = undefined; + }); + + it('canUndo should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false }); + }); + }); + }); + + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts new file mode 100644 index 0000000000..0b9bc62c55 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts @@ -0,0 +1,194 @@ +import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { hasValue, isNotEmpty } from '../../../../shared/empty.util'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { cloneDeep } from 'lodash'; +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { MetadataField } from '../../../../core/metadata/metadatafield.model'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { NgModel } from '@angular/forms'; +import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[ds-edit-in-place-field]', + styleUrls: ['./edit-in-place-field.component.scss'], + templateUrl: './edit-in-place-field.component.html', +}) +/** + * Component that displays a single metadatum of an item on the edit page + */ +export class EditInPlaceFieldComponent implements OnInit, OnChanges { + /** + * The current field, value and state of the metadatum + */ + @Input() fieldUpdate: FieldUpdate; + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * List of strings with all metadata field keys available + */ + @Input() metadataFields: string[]; + + /** + * The metadatum of this field + */ + metadata: MetadatumViewModel; + + /** + * Emits whether or not this field is currently editable + */ + editable: Observable; + + /** + * Emits whether or not this field is currently valid + */ + valid: Observable; + + /** + * The current suggestions for the metadatafield when editing + */ + metadataFieldSuggestions: BehaviorSubject = new BehaviorSubject([]); + + constructor( + private metadataFieldService: RegistryService, + private objectUpdatesService: ObjectUpdatesService, + ) { + } + + /** + * Sets up an observable that keeps track of the current editable and valid state of this field + */ + ngOnInit(): void { + this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid); + this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid); + } + + /** + * Sends a new change update for this field to the object updates service + */ + update(ngModel?: NgModel) { + this.objectUpdatesService.saveChangeFieldUpdate(this.url, this.metadata); + if (hasValue(ngModel)) { + this.checkValidity(ngModel); + } + } + + /** + * Method to check the validity of a form control + * @param ngModel + */ + private checkValidity(ngModel: NgModel) { + ngModel.control.setValue(ngModel.viewModel); + ngModel.control.updateValueAndValidity(); + this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, ngModel.control.valid); + } + + /** + * Sends a new editable state for this field to the service to change it + * @param editable The new editable state for this field + */ + setEditable(editable: boolean) { + this.objectUpdatesService.setEditableFieldUpdate(this.url, this.metadata.uuid, editable); + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove() { + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.metadata); + } + + /** + * Notifies the object updates service that the updates for the current field can be removed + */ + removeChangesFromField() { + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid); + } + + /** + * Sets the current metadatafield based on the fieldUpdate input field + */ + ngOnChanges(): void { + this.metadata = cloneDeep(this.fieldUpdate.field) as MetadatumViewModel; + } + + /** + * Requests all metadata fields that contain the query string in their key + * Then sets all found metadata fields as metadataFieldSuggestions + * @param query The query to look for + */ + findMetadataFieldSuggestions(query: string): void { + if (isNotEmpty(query)) { + this.metadataFieldService.queryMetadataFields(query).pipe( + // getSucceededRemoteData(), + take(1), + map((data) => data.payload.page) + ).subscribe( + (fields: MetadataField[]) => this.metadataFieldSuggestions.next( + fields.map((field: MetadataField) => { + return { + displayValue: field.toString().split('.').join('.​'), + value: field.toString() + }; + }) + ) + ); + } else { + this.metadataFieldSuggestions.next([]); + } + } + + /** + * Check if a user should be allowed to edit this field + * @return an observable that emits true when the user should be able to edit this field and false when they should not + */ + canSetEditable(): Observable { + return this.editable.pipe( + map((editable: boolean) => { + if (editable) { + return false; + } else { + return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + }) + ); + } + + /** + * Check if a user should be allowed to disabled editing this field + * @return an observable that emits true when the user should be able to disable editing this field and false when they should not + */ + canSetUneditable(): Observable { + return this.editable; + } + + /** + * Check if a user should be allowed to remove this field + * @return an observable that emits true when the user should be able to remove this field and false when they should not + */ + canRemove(): Observable { + return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD); + } + + /** + * Check if a user should be allowed to undo changes to this field + * @return an observable that emits true when the user should be able to undo changes to this field and false when they should not + */ + canUndo(): Observable { + return this.editable.pipe( + map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable) + ); + } + + protected isNotEmpty(value): boolean { + return isNotEmpty(value); + } +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html new file mode 100644 index 0000000000..496429a3ba --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -0,0 +1,64 @@ + diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss new file mode 100644 index 0000000000..f3075702e6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/variables.scss'; + +.button-row { + .btn { + margin-right: 0.5 * $spacer; + + &:last-child { + margin-right: 0; + } + + @media screen and (min-width: map-get($grid-breakpoints, sm)) { + min-width: $edit-item-button-min-width; + } + } + + &.top .btn { + margin-top: $spacer/2; + margin-bottom: $spacer/2; + } + + +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts new file mode 100644 index 0000000000..f2cd74fc2f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts @@ -0,0 +1,278 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { getTestScheduler } from 'jasmine-marbles'; +import { ItemMetadataComponent } from './item-metadata.component'; +import { TestScheduler } from 'rxjs/testing'; +import { SharedModule } from '../../../shared/shared.module'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { By } from '@angular/platform-browser'; +import { + INotification, + Notification +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { Item } from '../../../core/shared/item.model'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { RemoteData } from '../../../core/data/remote-data'; +import { MetadatumViewModel } from '../../../core/shared/metadata.models'; +import { RegistryService } from '../../../core/registry/registry.service'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; +import { MetadataField } from '../../../core/metadata/metadatafield.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; + +let comp: ItemMetadataComponent; +let fixture: ComponentFixture; +let de: DebugElement; +let el: HTMLElement; +let objectUpdatesService; +const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); +const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); +const date = new Date(); +const router = new RouterStub(); +let metadataFieldService; +let paginatedMetadataFields; +let routeStub; + +const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }); +const mdField1 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'contributor', + qualifier: 'author' +}); +const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' }); +const mdField3 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'description', + qualifier: 'abstract' +}); + +let itemService; +const notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } +); +const metadatum1 = Object.assign(new MetadatumViewModel(), { + key: 'dc.description.abstract', + value: 'Example abstract', + language: 'en' +}); + +const metadatum2 = Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Title test', + language: 'de' +}); + +const metadatum3 = Object.assign(new MetadatumViewModel(), { + key: 'dc.contributor.author', + value: 'Shakespeare, William', +}); + +const url = 'http://test-url.com/test-url'; + +router.url = url; + +const fieldUpdate1 = { + field: metadatum1, + changeType: undefined +}; + +const fieldUpdate2 = { + field: metadatum2, + changeType: FieldChangeType.REMOVE +}; + +const fieldUpdate3 = { + field: metadatum3, + changeType: undefined +}; + +let scheduler: TestScheduler; +let item; +describe('ItemMetadataComponent', () => { + beforeEach(async(() => { + item = Object.assign(new Item(), { + metadata: { + [metadatum1.key]: [metadatum1], + [metadatum2.key]: [metadatum2], + [metadatum3.key]: [metadatum3] + } + }, + { + lastModified: date + } + ) + ; + itemService = jasmine.createSpyObj('itemService', { + update: observableOf(new RemoteData(false, false, true, undefined, item)), + commitUpdates: {} + }); + routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, item) }) + } + }; + paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); + + metadataFieldService = jasmine.createSpyObj({ + getAllMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)) + }); + scheduler = getTestScheduler(); + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [metadatum1.uuid]: fieldUpdate1, + [metadatum2.uuid]: fieldUpdate2, + [metadatum3.uuid]: fieldUpdate3 + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) + } + ); + + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + declarations: [ItemMetadataComponent], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, + { provide: RegistryService, useValue: metadataFieldService }, + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemMetadataComponent); + comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance + de = fixture.debugElement; + el = de.nativeElement; + comp.url = url; + fixture.detectChanges(); + }); + + describe('add', () => { + const md = new MetadatumViewModel(); + beforeEach(() => { + comp.add(md); + }); + + it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md); + }); + }); + + describe('discard', () => { + beforeEach(() => { + comp.discard(); + }); + + it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => { + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); + }); + }); + + describe('reinstate', () => { + beforeEach(() => { + comp.reinstate(); + }); + + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => { + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); + }); + }); + + describe('submit', () => { + beforeEach(() => { + comp.submit(); + }); + + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList); + expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) })); + expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList); + }); + }); + + describe('hasChanges', () => { + describe('when the objectUpdatesService\'s hasUpdated method returns true', () => { + beforeEach(() => { + objectUpdatesService.hasUpdates.and.returnValue(observableOf(true)); + }); + + it('should return an observable that emits true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: true }); + }); + }); + + describe('when the objectUpdatesService\'s hasUpdated method returns false', () => { + beforeEach(() => { + objectUpdatesService.hasUpdates.and.returnValue(observableOf(false)); + }); + + it('should return an observable that emits false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: false }); + }); + }); + }); + + describe('changeType is UPDATE', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.UPDATE; + fixture.detectChanges(); + }); + it('the div should have class table-warning', () => { + const element = de.queryAll(By.css('tr'))[1].nativeElement; + expect(element.classList).toContain('table-warning'); + }); + }); + + describe('changeType is ADD', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.ADD; + fixture.detectChanges(); + }); + it('the div should have class table-success', () => { + const element = de.queryAll(By.css('tr'))[1].nativeElement; + expect(element.classList).toContain('table-success'); + }); + }); + + describe('changeType is REMOVE', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class table-danger', () => { + const element = de.queryAll(By.css('tr'))[1].nativeElement; + expect(element.classList).toContain('table-danger'); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts new file mode 100644 index 0000000000..6b3e05c818 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -0,0 +1,233 @@ +import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { cloneDeep } from 'lodash'; +import { Observable } from 'rxjs'; +import { + FieldUpdate, + FieldUpdates, + Identifiable +} from '../../../core/data/object-updates/object-updates.reducer'; +import { first, map, switchMap, take, tap } from 'rxjs/operators'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { TranslateService } from '@ngx-translate/core'; +import { RegistryService } from '../../../core/registry/registry.service'; +import { MetadataField } from '../../../core/metadata/metadatafield.model'; +import { MetadatumViewModel } from '../../../core/shared/metadata.models'; +import { Metadata } from '../../../core/shared/metadata.utils'; + +@Component({ + selector: 'ds-item-metadata', + styleUrls: ['./item-metadata.component.scss'], + templateUrl: './item-metadata.component.html', +}) +/** + * Component for displaying an item's metadata edit page + */ +export class ItemMetadataComponent implements OnInit { + + /** + * The item to display the edit page for + */ + item: Item; + /** + * The current values and updates for all this item's metadata fields + */ + updates$: Observable; + /** + * The current url of this page + */ + url: string; + /** + * The time span for being able to undo discarding changes + */ + private discardTimeOut: number; + /** + * Prefix for this component's notification translate keys + */ + private notificationsPrefix = 'item.edit.metadata.notifications.'; + + /** + * Observable with a list of strings with all existing metadata field keys + */ + metadataFields$: Observable; + + constructor( + private itemService: ItemDataService, + private objectUpdatesService: ObjectUpdatesService, + private router: Router, + private notificationsService: NotificationsService, + private translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private route: ActivatedRoute, + private metadataFieldService: RegistryService, + ) { + + } + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + this.metadataFields$ = this.findMetadataFields(); + this.route.parent.data.pipe(map((data) => data.item)) + .pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.item = item; + }); + + this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); + } + this.hasChanges().pipe(first()).subscribe((hasChanges) => { + if (!hasChanges) { + this.initializeOriginalFields(); + } else { + this.checkLastModified(); + } + }); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + } + + /** + * Sends a new add update for a field to the object updates service + * @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum + */ + add(metadata: MetadatumViewModel = new MetadatumViewModel()) { + this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); + + } + + /** + * Request the object updates service to discard all current changes to this item + * Shows a notification to remind the user that they can undo this + */ + discard() { + const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); + this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); + } + + /** + * Request the object updates service to undo discarding all changes to this item + */ + reinstate() { + this.objectUpdatesService.reinstateFieldUpdates(this.url); + } + + /** + * Sends all initial values of this item to the object updates service + */ + private initializeOriginalFields() { + this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); + } + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + + /** + * Requests all current metadata for this item and requests the item service to update the item + * Makes sure the new version of the item is rendered on the page + */ + submit() { + this.isValid().pipe(first()).subscribe((isValid) => { + if (isValid) { + const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable; + metadata$.pipe( + first(), + switchMap((metadata: MetadatumViewModel[]) => { + const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) }); + return this.itemService.update(updatedItem); + }), + tap(() => this.itemService.commitUpdates()), + getSucceededRemoteData() + ).subscribe( + (rd: RemoteData) => { + this.item = rd.payload; + this.initializeOriginalFields(); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + } + ) + } else { + this.notificationsService.error(this.getNotificationTitle('invalid'), this.getNotificationContent('invalid')); + } + }); + } + + /** + * Checks whether or not there are currently updates for this item + */ + hasChanges(): Observable { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Checks whether or not the item is currently reinstatable + */ + isReinstatable(): Observable { + return this.objectUpdatesService.isReinstatable(this.url); + } + + /** + * Checks if the current item is still in sync with the version in the store + * If it's not, a notification is shown and the changes are removed + */ + private checkLastModified() { + const currentVersion = this.item.lastModified; + this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( + (updateVersion: Date) => { + if (updateVersion.getDate() !== currentVersion.getDate()) { + this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); + this.initializeOriginalFields(); + } + } + ); + } + + /** + * Check if the current page is entirely valid + */ + private isValid() { + return this.objectUpdatesService.isValidPage(this.url); + } + + /** + * Get translated notification title + * @param key + */ + private getNotificationTitle(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + private getNotificationContent(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } + + /** + * Method to request all metadata fields and convert them to a list of strings + */ + findMetadataFields(): Observable { + return this.metadataFieldService.getAllMetadataFields().pipe( + getSucceededRemoteData(), + take(1), + map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); + } +} diff --git a/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts b/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts index 9f9447704b..651bebde58 100644 --- a/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts @@ -1,20 +1,20 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Item} from '../../../core/shared/item.model'; -import {RouterStub} from '../../../shared/testing/router-stub'; -import {of as observableOf} from 'rxjs'; -import {RemoteData} from '../../../core/data/remote-data'; -import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; -import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; -import {RouterTestingModule} from '@angular/router/testing'; -import {TranslateModule} from '@ngx-translate/core'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; -import {By} from '@angular/platform-browser'; -import {ItemPrivateComponent} from './item-private.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemPrivateComponent } from './item-private.component'; import { RestResponse } from '../../../core/cache/response.models'; let comp: ItemPrivateComponent; @@ -44,8 +44,8 @@ describe('ItemPrivateComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setDiscoverable: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setDiscoverable: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemPrivateComponent', () => { imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [ItemPrivateComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataService}, - {provide: NotificationsService, useValue: notificationsServiceStub}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemPrivateComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemPrivateComponent); comp = fixture.componentInstance; diff --git a/src/app/+item-page/edit-item-page/item-private/item-private.component.ts b/src/app/+item-page/edit-item-page/item-private/item-private.component.ts index 4947f920d0..d949e4fa6e 100644 --- a/src/app/+item-page/edit-item-page/item-private/item-private.component.ts +++ b/src/app/+item-page/edit-item-page/item-private/item-private.component.ts @@ -1,8 +1,8 @@ -import {Component} from '@angular/core'; -import {first} from 'rxjs/operators'; -import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; -import {RemoteData} from '../../../core/data/remote-data'; -import {Item} from '../../../core/shared/item.model'; +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models'; @Component({ diff --git a/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts b/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts index 3b9f8ed00a..7516a84265 100644 --- a/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts @@ -1,20 +1,20 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Item} from '../../../core/shared/item.model'; -import {RouterStub} from '../../../shared/testing/router-stub'; -import {of as observableOf} from 'rxjs'; -import {RemoteData} from '../../../core/data/remote-data'; -import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; -import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; -import {RouterTestingModule} from '@angular/router/testing'; -import {TranslateModule} from '@ngx-translate/core'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; -import {By} from '@angular/platform-browser'; -import {ItemPublicComponent} from './item-public.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemPublicComponent } from './item-public.component'; import { RestResponse } from '../../../core/cache/response.models'; let comp: ItemPublicComponent; @@ -44,8 +44,8 @@ describe('ItemPublicComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setDiscoverable: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setDiscoverable: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemPublicComponent', () => { imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [ItemPublicComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataService}, - {provide: NotificationsService, useValue: notificationsServiceStub}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemPublicComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemPublicComponent); comp = fixture.componentInstance; diff --git a/src/app/+item-page/edit-item-page/item-public/item-public.component.ts b/src/app/+item-page/edit-item-page/item-public/item-public.component.ts index 3d8d2755d8..272cf9a96f 100644 --- a/src/app/+item-page/edit-item-page/item-public/item-public.component.ts +++ b/src/app/+item-page/edit-item-page/item-public/item-public.component.ts @@ -1,8 +1,8 @@ -import {Component} from '@angular/core'; -import {first} from 'rxjs/operators'; -import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; -import {RemoteData} from '../../../core/data/remote-data'; -import {Item} from '../../../core/shared/item.model'; +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models'; @Component({ diff --git a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts index 98897cf2ae..f606fb4a83 100644 --- a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts @@ -1,20 +1,20 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Item} from '../../../core/shared/item.model'; -import {RouterStub} from '../../../shared/testing/router-stub'; -import {of as observableOf} from 'rxjs'; -import {RemoteData} from '../../../core/data/remote-data'; -import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; -import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; -import {RouterTestingModule} from '@angular/router/testing'; -import {TranslateModule} from '@ngx-translate/core'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; -import {By} from '@angular/platform-browser'; -import {ItemReinstateComponent} from './item-reinstate.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemReinstateComponent } from './item-reinstate.component'; import { RestResponse } from '../../../core/cache/response.models'; let comp: ItemReinstateComponent; @@ -44,8 +44,8 @@ describe('ItemReinstateComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setWithDrawn: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setWithDrawn: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemReinstateComponent', () => { imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [ItemReinstateComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataService}, - {provide: NotificationsService, useValue: notificationsServiceStub}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemReinstateComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemReinstateComponent); comp = fixture.componentInstance; diff --git a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts index 94f03d10bd..9c0e1c8d05 100644 --- a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts +++ b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts @@ -1,8 +1,8 @@ -import {Component} from '@angular/core'; -import {first} from 'rxjs/operators'; -import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; -import {RemoteData} from '../../../core/data/remote-data'; -import {Item} from '../../../core/shared/item.model'; +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models'; @Component({ diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html index 0f7d9a5607..e60fa0490d 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -12,7 +12,7 @@ {{'item.edit.tabs.status.labels.itemPage' | translate}}:
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts index 319d4c47ae..00ea9b9f62 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts @@ -6,11 +6,12 @@ import { CommonModule } from '@angular/common'; import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub'; import { HostWindowService } from '../../../shared/host-window.service'; import { RouterTestingModule } from '@angular/router/testing'; -import { Router } from '@angular/router'; -import { RouterStub } from '../../../shared/testing/router-stub'; +import { ActivatedRoute } from '@angular/router'; import { Item } from '../../../core/shared/item.model'; import { By } from '@angular/platform-browser'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; describe('ItemStatusComponent', () => { let comp: ItemStatusComponent; @@ -22,17 +23,20 @@ describe('ItemStatusComponent', () => { lastModified: '2018' }); - const itemPageUrl = `fake-url/${mockItem.id}`; - const routerStub = Object.assign(new RouterStub(), { - url: `${itemPageUrl}/edit` - }); + const itemPageUrl = `items/${mockItem.id}`; + + const routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) }) + } + }; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [ItemStatusComponent], providers: [ - { provide: Router, useValue: routerStub }, + { provide: ActivatedRoute, useValue: routeStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -41,7 +45,6 @@ describe('ItemStatusComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ItemStatusComponent); comp = fixture.componentInstance; - comp.item = mockItem; fixture.detectChanges(); }); @@ -65,4 +68,5 @@ describe('ItemStatusComponent', () => { expect(statusItemPage.textContent).toContain(itemPageUrl); }); -}); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index f37b6e9c70..79197a0c50 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -1,8 +1,12 @@ -import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; import { Item } from '../../../core/shared/item.model'; -import { Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { ItemOperation } from '../item-operation/itemOperation.model'; +import { first, map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module'; @Component({ selector: 'ds-item-status', @@ -21,7 +25,7 @@ export class ItemStatusComponent implements OnInit { /** * The item to display the status for */ - @Input() item: Item; + itemRD$: Observable>; /** * The data to show in the status @@ -38,55 +42,62 @@ export class ItemStatusComponent implements OnInit { */ operations: ItemOperation[]; - constructor(private router: Router) { + /** + * The keys of the actions (to loop over) + */ + actionsKeys; + + constructor(private route: ActivatedRoute) { } ngOnInit(): void { - this.statusData = Object.assign({ - id: this.item.id, - handle: this.item.handle, - lastModified: this.item.lastModified + this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)); + this.itemRD$.pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.statusData = Object.assign({ + id: item.id, + handle: item.handle, + lastModified: item.lastModified + }); + this.statusDataKeys = Object.keys(this.statusData); + /* + The key is used to build messages + i18n example: 'item.edit.tabs.status.buttons..label' + The value is supposed to be a href for the button + */ + this.operations = []; + this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); + if (item.isWithdrawn) { + this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); + } else { + this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw')); + } + if (item.isDiscoverable) { + this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); + } else { + this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); + } + this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); }); - this.statusDataKeys = Object.keys(this.statusData); - /* - The key is used to build messages - i18n example: 'item.edit.tabs.status.buttons..label' - The value is supposed to be a href for the button - */ - this.operations = []; - this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl() + '/mapper')); - if (this.item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); - } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); - } - if (this.item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); - } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); - } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); } /** * Get the url to the simple item page * @returns {string} url */ - getItemPage(): string { - return this.router.url.substr(0, this.router.url.lastIndexOf('/')); + getItemPage(item: Item): string { + return getItemPageRoute(item.id) } /** * Get the current url without query params * @returns {string} url */ - getCurrentUrl(): string { - if (this.router.url.indexOf('?') > -1) { - return this.router.url.substr(0, this.router.url.indexOf('?')); - } else { - return this.router.url; - } + getCurrentUrl(item: Item): string { + return getItemEditPath(item.id); } } diff --git a/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts index 130cdd0d25..ac49eee7e7 100644 --- a/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts @@ -1,20 +1,20 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Item} from '../../../core/shared/item.model'; -import {RouterStub} from '../../../shared/testing/router-stub'; -import {of as observableOf} from 'rxjs'; -import {RemoteData} from '../../../core/data/remote-data'; -import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; -import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; -import {RouterTestingModule} from '@angular/router/testing'; -import {TranslateModule} from '@ngx-translate/core'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; -import {ItemWithdrawComponent} from './item-withdraw.component'; -import {By} from '@angular/platform-browser'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ItemWithdrawComponent } from './item-withdraw.component'; +import { By } from '@angular/platform-browser'; import { RestResponse } from '../../../core/cache/response.models'; let comp: ItemWithdrawComponent; @@ -44,8 +44,8 @@ describe('ItemWithdrawComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setWithDrawn: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setWithDrawn: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemWithdrawComponent', () => { imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot(),], declarations: [ItemWithdrawComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataService}, - {provide: NotificationsService, useValue: notificationsServiceStub}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemWithdrawComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemWithdrawComponent); comp = fixture.componentInstance; diff --git a/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts index 7681fa68b5..1fed1756a4 100644 --- a/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts +++ b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts @@ -1,8 +1,8 @@ -import {Component} from '@angular/core'; -import {first} from 'rxjs/operators'; -import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; -import {RemoteData} from '../../../core/data/remote-data'; -import {Item} from '../../../core/shared/item.model'; +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models'; @Component({ diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html index d59d29ddbf..ce6e01df3d 100644 --- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html @@ -7,10 +7,12 @@ - - {{metadatum.key}} - {{metadatum.value}} - {{metadatum.language}} - + + + {{mdEntry.key}} + {{mdValue.value}} + {{mdValue.language}} + + - \ No newline at end of file + diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts index 942357dc5a..07ad9a347c 100644 --- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts @@ -11,10 +11,14 @@ const mockItem = Object.assign(new Item(), { id: 'fake-id', handle: 'fake/handle', lastModified: '2018', - metadata: [ - {key: 'dc.title', value: 'Mock item title', language: 'en'}, - {key: 'dc.contributor.author', value: 'Mayer, Ed', language: ''} - ] + metadata: { + 'dc.title': [ + { value: 'Mock item title', language: 'en' } + ], + 'dc.contributor.author': [ + { value: 'Mayer, Ed', language: '' } + ] + } }); describe('ModifyItemOverviewComponent', () => { @@ -37,19 +41,19 @@ describe('ModifyItemOverviewComponent', () => { const metadataRows = fixture.debugElement.queryAll(By.css('tr.metadata-row')); expect(metadataRows.length).toEqual(2); - const titleRow = metadataRows[0].queryAll(By.css('td')); - expect(titleRow.length).toEqual(3); - - expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title'); - expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title'); - expect(titleRow[2].nativeElement.innerHTML).toContain('en'); - - const authorRow = metadataRows[1].queryAll(By.css('td')); + const authorRow = metadataRows[0].queryAll(By.css('td')); expect(authorRow.length).toEqual(3); expect(authorRow[0].nativeElement.innerHTML).toContain('dc.contributor.author'); expect(authorRow[1].nativeElement.innerHTML).toContain('Mayer, Ed'); expect(authorRow[2].nativeElement.innerHTML).toEqual(''); + const titleRow = metadataRows[1].queryAll(By.css('td')); + expect(titleRow.length).toEqual(3); + + expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title'); + expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title'); + expect(titleRow[2].nativeElement.innerHTML).toContain('en'); + }); }); diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts index d32a98d5e0..974bc8d37f 100644 --- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts @@ -1,6 +1,6 @@ import {Component, Input, OnInit} from '@angular/core'; import {Item} from '../../../core/shared/item.model'; -import {Metadatum} from '../../../core/shared/metadatum.model'; +import {MetadataMap} from '../../../core/shared/metadata.models'; @Component({ selector: 'ds-modify-item-overview', @@ -12,7 +12,7 @@ import {Metadatum} from '../../../core/shared/metadatum.model'; export class ModifyItemOverviewComponent implements OnInit { @Input() item: Item; - metadata: Metadatum[]; + metadata: MetadataMap; ngOnInit(): void { this.metadata = this.item.metadata; diff --git a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts index 7d3b75c97e..32acdef467 100644 --- a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts @@ -1,21 +1,21 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Item} from '../../../core/shared/item.model'; -import {RouterStub} from '../../../shared/testing/router-stub'; -import {CommonModule} from '@angular/common'; -import {RouterTestingModule} from '@angular/router/testing'; -import {TranslateModule} from '@ngx-translate/core'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; -import {FormsModule} from '@angular/forms'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {RemoteData} from '../../../core/data/remote-data'; -import {AbstractSimpleItemActionComponent} from './abstract-simple-item-action.component'; -import {By} from '@angular/platform-browser'; -import {of as observableOf} from 'rxjs'; -import {getItemEditPath} from '../../item-page-routing.module'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { AbstractSimpleItemActionComponent } from './abstract-simple-item-action.component'; +import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; +import { getItemEditPath } from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models'; /** @@ -82,10 +82,10 @@ describe('AbstractSimpleItemActionComponent', () => { imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [MySimpleItemActionComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataService}, - {provide: NotificationsService, useValue: notificationsServiceStub}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -93,14 +93,19 @@ describe('AbstractSimpleItemActionComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(MySimpleItemActionComponent); comp = fixture.componentInstance; fixture.detectChanges(); }); + afterEach(() => { + fixture.destroy(); + comp = null; + }); + it('should render a page with messages based on the provided messageKey', () => { const header = fixture.debugElement.query(By.css('h2')).nativeElement; expect(header.innerHTML).toContain('item.edit.myEditAction.header'); @@ -124,7 +129,6 @@ describe('AbstractSimpleItemActionComponent', () => { }); it('should process a RestResponse to navigate and display success notification', () => { - spyOn(notificationsServiceStub, 'success'); comp.processRestResponse(successfulRestResponse); expect(notificationsServiceStub.success).toHaveBeenCalled(); @@ -132,7 +136,6 @@ describe('AbstractSimpleItemActionComponent', () => { }); it('should process a RestResponse to navigate and display success notification', () => { - spyOn(notificationsServiceStub, 'error'); comp.processRestResponse(failRestResponse); expect(notificationsServiceStub.error).toHaveBeenCalled(); diff --git a/src/app/+item-page/field-components/collections/collections.component.spec.ts b/src/app/+item-page/field-components/collections/collections.component.spec.ts index 865ce78a39..53fcded9e3 100644 --- a/src/app/+item-page/field-components/collections/collections.component.spec.ts +++ b/src/app/+item-page/field-components/collections/collections.component.spec.ts @@ -14,12 +14,14 @@ let collectionsComponent: CollectionsComponent; let fixture: ComponentFixture; const mockCollection1: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: observableOf(new RemoteData(false, false, true, null, mockCollection1))}); diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html index bbe6d8d95b..c791cec600 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html @@ -1,4 +1,4 @@ -
+
{{ label }}
diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts index cce54edf64..d7e1b80c76 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts @@ -1,18 +1,41 @@ -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Component, DebugElement } from '@angular/core'; +import { Component } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component'; +/* tslint:disable:max-classes-per-file */ @Component({ - selector: 'ds-component-with-content', + selector: 'ds-component-without-content', template: '\n' + - '
\n' + - ' \n' + - '
\n' + '
' }) -class ContentComponent {} +class NoContentComponent {} + +@Component({ + selector: 'ds-component-with-empty-spans', + template: '\n' + + ' \n' + + ' \n' + + '' +}) +class SpanContentComponent {} + +@Component({ + selector: 'ds-component-with-text', + template: '\n' + + ' The quick brown fox jumps over the lazy dog\n' + + '' +}) +class TextContentComponent {} + +@Component({ + selector: 'ds-component-with-image', + template: '\n' + + ' an alt text\n' + + '' +}) +class ImgContentComponent {} +/* tslint:enable:max-classes-per-file */ describe('MetadataFieldWrapperComponent', () => { let component: MetadataFieldWrapperComponent; @@ -20,7 +43,7 @@ describe('MetadataFieldWrapperComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [MetadataFieldWrapperComponent, ContentComponent] + declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent] }).compileComponents(); })); @@ -30,23 +53,21 @@ describe('MetadataFieldWrapperComponent', () => { }); const wrapperSelector = '.simple-view-element'; - const labelSelector = '.simple-view-element-header'; - const contentSelector = '.my-content'; it('should create', () => { expect(component).toBeDefined(); }); it('should not show the component when there is no content', () => { - component.label = 'test label'; - fixture.detectChanges(); - const parentNative = fixture.nativeElement; + const parentFixture = TestBed.createComponent(NoContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; const nativeWrapper = parentNative.querySelector(wrapperSelector); expect(nativeWrapper.classList.contains('d-none')).toBe(true); }); - it('should not show the component when there is DOM content but no text', () => { - const parentFixture = TestBed.createComponent(ContentComponent); + it('should not show the component when there is DOM content but not text or an image', () => { + const parentFixture = TestBed.createComponent(SpanContentComponent); parentFixture.detectChanges(); const parentNative = parentFixture.nativeElement; const nativeWrapper = parentNative.querySelector(wrapperSelector); @@ -54,11 +75,18 @@ describe('MetadataFieldWrapperComponent', () => { }); it('should show the component when there is text content', () => { - const parentFixture = TestBed.createComponent(ContentComponent); + const parentFixture = TestBed.createComponent(TextContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + parentFixture.detectChanges(); + expect(nativeWrapper.classList.contains('d-none')).toBe(false); + }); + + it('should show the component when there is img content', () => { + const parentFixture = TestBed.createComponent(ImgContentComponent); parentFixture.detectChanges(); const parentNative = parentFixture.nativeElement; - const nativeContent = parentNative.querySelector(contentSelector); - nativeContent.textContent = 'lorem ipsum'; const nativeWrapper = parentNative.querySelector(wrapperSelector); parentFixture.detectChanges(); expect(nativeWrapper.classList.contains('d-none')).toBe(false); diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts index 8c80384732..8af108cceb 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts @@ -1,4 +1,5 @@ import { Component, Input } from '@angular/core'; +import { hasNoValue } from '../../../shared/empty.util'; /** * This component renders any content inside this wrapper. @@ -11,6 +12,15 @@ import { Component, Input } from '@angular/core'; }) export class MetadataFieldWrapperComponent { + /** + * The label (title) for the content + */ @Input() label: string; + /** + * Make hasNoValue() available in the template + */ + hasNoValue(o: any): boolean { + return hasNoValue(o); + } } diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html index cc618bcd50..b5d7c118dd 100644 --- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html @@ -1,5 +1,5 @@ - - {{ linktext || metadatum.value }} + + {{ linktext || mdValue.value }} diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts new file mode 100644 index 0000000000..2b32ece3c3 --- /dev/null +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts @@ -0,0 +1,97 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader'; +import { By } from '@angular/platform-browser'; +import { MetadataUriValuesComponent } from './metadata-uri-values.component'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { MetadataValue } from '../../../core/shared/metadata.models'; + +let comp: MetadataUriValuesComponent; +let fixture: ComponentFixture; + +const mockMetadata = [ + { + language: 'en_US', + value: 'http://fakelink.org' + }, + { + language: 'en_US', + value: 'http://another.fakelink.org' + } +] as MetadataValue[]; +const mockSeperator = '
'; +const mockLabel = 'fake.message'; +const mockLinkText = 'fake link text'; + +describe('MetadataUriValuesComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [MetadataUriValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataUriValuesComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(MetadataUriValuesComponent); + comp = fixture.componentInstance; + comp.mdValues = mockMetadata; + comp.separator = mockSeperator; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display all metadata values', () => { + const innerHTML = fixture.nativeElement.innerHTML; + for (const metadatum of mockMetadata) { + expect(innerHTML).toContain(metadatum.value); + } + }); + + it('should contain the correct hrefs', () => { + const links = fixture.debugElement.queryAll(By.css('a')); + for (const metadatum of mockMetadata) { + expect(containsHref(links, metadatum.value)).toBeTruthy(); + } + }); + + it('should contain separators equal to the amount of metadata values minus one', () => { + const separators = fixture.debugElement.queryAll(By.css('a span')); + expect(separators.length).toBe(mockMetadata.length - 1); + }); + + describe('when linktext is defined', () => { + + beforeEach(() => { + comp.linktext = mockLinkText; + fixture.detectChanges(); + }); + + it('should replace the metadata value with the linktext', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.textContent).toContain(mockLinkText); + }); + + }); + +}); + +function containsHref(links: DebugElement[], href: string): boolean { + for (const link of links) { + const hrefAtt = link.properties.href; + if (isNotEmpty(hrefAtt)) { + if (hrefAtt === href) { + return true; + } + } + } + return false; +} diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 212dcddee8..e070eccf2d 100644 --- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts @@ -1,6 +1,7 @@ import { Component, Input } from '@angular/core'; import { MetadataValuesComponent } from '../metadata-values/metadata-values.component'; +import { MetadataValue } from '../../../core/shared/metadata.models'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link. @@ -16,11 +17,24 @@ import { MetadataValuesComponent } from '../metadata-values/metadata-values.comp }) export class MetadataUriValuesComponent extends MetadataValuesComponent { + /** + * Optional text to replace the links with + * If undefined, the metadata value (uri) is displayed + */ @Input() linktext: any; - @Input() values: any; + /** + * The metadata values to display + */ + @Input() mdValues: MetadataValue[]; + /** + * The seperator used to split the metadata values (can contain HTML) + */ @Input() separator: string; + /** + * The label for this iteration of metadata values + */ @Input() label: string; } diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.html b/src/app/+item-page/field-components/metadata-values/metadata-values.component.html index f16655c63c..980c940255 100644 --- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.html +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.html @@ -1,5 +1,5 @@ - - {{metadatum.value}} + + {{mdValue.value}} diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts new file mode 100644 index 0000000000..cad2edb98a --- /dev/null +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts @@ -0,0 +1,65 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader'; +import { MetadataValuesComponent } from './metadata-values.component'; +import { By } from '@angular/platform-browser'; +import { MetadataValue } from '../../../core/shared/metadata.models'; + +let comp: MetadataValuesComponent; +let fixture: ComponentFixture; + +const mockMetadata = [ + { + language: 'en_US', + value: '1234' + }, + { + language: 'en_US', + value: 'a publisher' + }, + { + language: 'en_US', + value: 'desc' + }] as MetadataValue[]; +const mockSeperator = '
'; +const mockLabel = 'fake.message'; + +describe('MetadataValuesComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataValuesComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(MetadataValuesComponent); + comp = fixture.componentInstance; + comp.mdValues = mockMetadata; + comp.separator = mockSeperator; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display all metadata values', () => { + const innerHTML = fixture.nativeElement.innerHTML; + for (const metadatum of mockMetadata) { + expect(innerHTML).toContain(metadatum.value); + } + }); + + it('should contain separators equal to the amount of metadata values minus one', () => { + const separators = fixture.debugElement.queryAll(By.css('span>span')); + expect(separators.length).toBe(mockMetadata.length - 1); + }); + +}); diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts index 1c94b56d57..142b08b360 100644 --- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { Metadatum } from '../../../core/shared/metadatum.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. @@ -12,10 +12,19 @@ import { Metadatum } from '../../../core/shared/metadatum.model'; }) export class MetadataValuesComponent { - @Input() values: Metadatum[]; + /** + * The metadata values to display + */ + @Input() mdValues: MetadataValue[]; + /** + * The seperator used to split the metadata values (can contain HTML) + */ @Input() separator: string; + /** + * The label for this iteration of metadata values + */ @Input() label: string; } diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index d5a7febeb9..a68993cd16 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -17,7 +17,7 @@
{{"item.page.filesection.description" | translate}}
-
{{file.findMetadata("dc.description")}}
+
{{file.firstMetadataValue("dc.description")}}
diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html index 1d0c4ab812..7aec57da0c 100644 --- a/src/app/+item-page/full/full-item-page.component.html +++ b/src/app/+item-page/full/full-item-page.component.html @@ -9,11 +9,13 @@
- - - - - + + + + + + +
{{metadatum.key}}{{metadatum.value}}{{metadatum.language}}
{{mdEntry.key}}{{mdValue.value}}{{mdValue.language}}
diff --git a/src/app/+item-page/full/full-item-page.component.spec.ts b/src/app/+item-page/full/full-item-page.component.spec.ts new file mode 100644 index 0000000000..15dd001964 --- /dev/null +++ b/src/app/+item-page/full/full-item-page.component.spec.ts @@ -0,0 +1,78 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { TruncatePipe } from '../../shared/utils/truncate.pipe'; +import { FullItemPageComponent } from './full-item-page.component'; +import { MetadataService } from '../../core/metadata/metadata.service'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Item } from '../../core/shared/item.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { RemoteData } from '../../core/data/remote-data'; +import { of as observableOf } from 'rxjs'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item' + } + ] + } +}); +const routeStub = Object.assign(new ActivatedRouteStub(), { + data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) }) +}); +const metadataServiceStub = { + /* tslint:disable:no-empty */ + processRemoteData: () => {} + /* tslint:enable:no-empty */ +}; + +describe('FullItemPageComponent', () => { + let comp: FullItemPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), RouterTestingModule.withRoutes([]), BrowserAnimationsModule], + declarations: [FullItemPageComponent, TruncatePipe, VarDirective], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: ItemDataService, useValue: {}}, + {provide: MetadataService, useValue: metadataServiceStub} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(FullItemPageComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(FullItemPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should display the item\'s metadata', () => { + const table = fixture.debugElement.query(By.css('table')); + for (const metadatum of mockItem.allMetadata([])) { + expect(table.nativeElement.innerHTML).toContain(metadatum.value); + } + }) +}); diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index d09ac268ec..b2a42b7c6f 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -1,12 +1,11 @@ - import {filter, map} from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable , BehaviorSubject } from 'rxjs'; import { ItemPageComponent } from '../simple/item-page.component'; -import { Metadatum } from '../../core/shared/metadatum.model'; +import { MetadataMap } from '../../core/shared/metadata.models'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -32,12 +31,12 @@ import { hasValue } from '../../shared/empty.util'; }) export class FullItemPageComponent extends ItemPageComponent implements OnInit { - itemRD$: Observable>; + itemRD$: BehaviorSubject>; - metadata$: Observable; + metadata$: Observable; - constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) { - super(route, items, metadataService); + constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService) { + super(route, router, items, metadataService); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 15968e2e4e..ec562842aa 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -4,9 +4,9 @@ import { RouterModule } from '@angular/router'; import { ItemPageComponent } from './simple/item-page.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { ItemPageResolver } from './item-page.resolver'; -import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { getItemModulePath } from '../app-routing.module'; import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getItemModulePath } from '../app-routing.module'; +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; export function getItemPageRoute(itemId: string) { return new URLCombiner(getItemModulePath(), itemId).toString(); @@ -39,7 +39,7 @@ const ITEM_EDIT_PATH = ':id/edit'; path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', canActivate: [AuthenticatedGuard] - } + }, ]) ], providers: [ diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index d383189a9c..123e3ea143 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -2,46 +2,77 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from './../shared/shared.module'; +import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component'; import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageRoutingModule } from './item-page-routing.module'; -import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; import { MetadataUriValuesComponent } from './field-components/metadata-uri-values/metadata-uri-values.component'; -import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component'; import { ItemPageAuthorFieldComponent } from './simple/field-components/specific-field/author/item-page-author-field.component'; import { ItemPageDateFieldComponent } from './simple/field-components/specific-field/date/item-page-date-field.component'; import { ItemPageAbstractFieldComponent } from './simple/field-components/specific-field/abstract/item-page-abstract-field.component'; import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component'; import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component'; -import { ItemPageSpecificFieldComponent } from './simple/field-components/specific-field/item-page-specific-field.component'; +import { ItemPageFieldComponent } from './simple/field-components/specific-field/item-page-field.component'; import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component'; +import { RelatedItemsComponent } from './simple/related-items/related-items-component'; +import { SearchPageModule } from '../+search-page/search-page.module'; +import { PublicationComponent } from './simple/item-types/publication/publication.component'; +import { PersonComponent } from './simple/item-types/person/person.component'; +import { OrgunitComponent } from './simple/item-types/orgunit/orgunit.component'; +import { ProjectComponent } from './simple/item-types/project/project.component'; +import { JournalComponent } from './simple/item-types/journal/journal.component'; +import { JournalVolumeComponent } from './simple/item-types/journal-volume/journal-volume.component'; +import { JournalIssueComponent } from './simple/item-types/journal-issue/journal-issue.component'; +import { ItemComponent } from './simple/item-types/shared/item.component'; import { EditItemPageModule } from './edit-item-page/edit-item-page.module'; +import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; +import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; @NgModule({ imports: [ CommonModule, SharedModule, EditItemPageModule, - ItemPageRoutingModule + ItemPageRoutingModule, + SearchPageModule ], declarations: [ ItemPageComponent, FullItemPageComponent, - MetadataValuesComponent, MetadataUriValuesComponent, - MetadataFieldWrapperComponent, ItemPageAuthorFieldComponent, ItemPageDateFieldComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, ItemPageTitleFieldComponent, - ItemPageSpecificFieldComponent, + ItemPageFieldComponent, FileSectionComponent, CollectionsComponent, - FullFileSectionComponent + FullFileSectionComponent, + PublicationComponent, + ProjectComponent, + OrgunitComponent, + PersonComponent, + RelatedItemsComponent, + ItemComponent, + GenericItemPageFieldComponent, + JournalComponent, + JournalIssueComponent, + JournalVolumeComponent, + MetadataRepresentationListComponent, + RelatedEntitiesSearchComponent + ], + entryComponents: [ + PublicationComponent, + ProjectComponent, + OrgunitComponent, + PersonComponent, + JournalComponent, + JournalIssueComponent, + JournalVolumeComponent ] }) export class ItemPageModule { diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index c0ee6a84ee..4b7ef23b69 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -2,9 +2,10 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; import { RemoteData } from '../core/data/remote-data'; -import { getSucceededRemoteData } from '../core/shared/operators'; import { ItemDataService } from '../core/data/item-data.service'; import { Item } from '../core/shared/item.model'; +import { hasValue } from '../shared/empty.util'; +import { find } from 'rxjs/operators'; /** * This class represents a resolver that requests a specific item before the route is activated @@ -18,11 +19,13 @@ export class ItemPageResolver implements Resolve> { * Method for resolving an item based on the parameters in the current route * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found item based on the parameters in the current route + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.itemService.findById(route.params.id).pipe( - getSucceededRemoteData() - ); + return this.itemService.findById(route.params.id) + .pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); } } diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts new file mode 100644 index 0000000000..9461ee0950 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemPageAbstractFieldComponent } from './item-page-abstract-field.component'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; + +let comp: ItemPageAbstractFieldComponent; +let fixture: ComponentFixture; + +const mockField = 'dc.description.abstract'; +const mockValue = 'test value'; + +describe('ItemPageAbstractFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageAbstractFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageAbstractFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageAbstractFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts index a8cc309ab6..00984d6592 100644 --- a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-abstract-field', - templateUrl: './../item-page-specific-field.component.html' + templateUrl: '../item-page-field.component.html' }) -export class ItemPageAbstractFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the abstract (dc.description.abstract) of an item + */ +export class ItemPageAbstractFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.description.abstract' + */ fields: string[] = [ 'dc.description.abstract' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.abstract'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts new file mode 100644 index 0000000000..d865caff8a --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts @@ -0,0 +1,45 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageAuthorFieldComponent } from './item-page-author-field.component'; + +let comp: ItemPageAuthorFieldComponent; +let fixture: ComponentFixture; + +const mockFields = ['dc.contributor.author', 'dc.creator', 'dc.contributor']; +const mockValue = 'test value'; + +describe('ItemPageAuthorFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageAuthorFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + for (const field of mockFields) { + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageAuthorFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(field, mockValue); + fixture.detectChanges(); + })); + + describe(`when the item contains metadata for ${field}`, () => { + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); + }); + } +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts index e84a52d1b9..51941d2cc8 100644 --- a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts @@ -1,24 +1,41 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-author-field', - templateUrl: './../item-page-specific-field.component.html' + templateUrl: '../item-page-field.component.html' }) -export class ItemPageAuthorFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the author (dc.contributor.author, dc.creator and dc.contributor) metadata of an item + */ +export class ItemPageAuthorFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.contributor.author', 'dc.creator' and 'dc.contributor' + */ fields: string[] = [ 'dc.contributor.author', 'dc.creator', 'dc.contributor' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.author'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts new file mode 100644 index 0000000000..2adada582b --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageDateFieldComponent } from './item-page-date-field.component'; + +let comp: ItemPageDateFieldComponent; +let fixture: ComponentFixture; + +const mockField = 'dc.date.issued'; +const mockValue = 'test value'; + +describe('ItemPageDateFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageDateFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageDateFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageDateFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts index 6950944f87..5a7d56b7da 100644 --- a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-date-field', - templateUrl: './../item-page-specific-field.component.html' + templateUrl: '../item-page-field.component.html' }) -export class ItemPageDateFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the issue date (dc.date.issued) metadata of an item + */ +export class ItemPageDateFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator = ', '; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.date.issued' + */ fields: string[] = [ 'dc.date.issued' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.date'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts new file mode 100644 index 0000000000..d8abd39cf3 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts @@ -0,0 +1,45 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { GenericItemPageFieldComponent } from './generic-item-page-field.component'; + +let comp: GenericItemPageFieldComponent; +let fixture: ComponentFixture; + +const mockValue = 'test value'; +const mockField = 'dc.test'; +const mockLabel = 'test label'; +const mockFields = [mockField]; + +describe('GenericItemPageFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [GenericItemPageFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(GenericItemPageFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(GenericItemPageFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.fields = mockFields; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts new file mode 100644 index 0000000000..ee7d27a11f --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts @@ -0,0 +1,38 @@ +import { Component, Input } from '@angular/core'; + +import { Item } from '../../../../../core/shared/item.model'; +import { ItemPageFieldComponent } from '../item-page-field.component'; + +@Component({ + selector: 'ds-generic-item-page-field', + templateUrl: '../item-page-field.component.html' +}) +/** + * This component can be used to represent metadata on a simple item page. + * It is the most generic way of displaying metadata values + * It expects 4 parameters: The item, a seperator, the metadata keys and an i18n key + */ +export class GenericItemPageFieldComponent extends ItemPageFieldComponent { + + /** + * The item to display metadata for + */ + @Input() item: Item; + + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ + @Input() separator: string; + + /** + * Fields (schema.element.qualifier) used to render their values. + */ + @Input() fields: string[]; + + /** + * Label i18n key for the rendered metadata + */ + @Input() label: string; + +} diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html new file mode 100644 index 0000000000..fd3055d197 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts new file mode 100644 index 0000000000..ea6e722c66 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts @@ -0,0 +1,63 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { Observable } from 'rxjs'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { ItemPageFieldComponent } from './item-page-field.component'; +import { MetadataValuesComponent } from '../../../field-components/metadata-values/metadata-values.component'; +import { of as observableOf } from 'rxjs'; +import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models'; + +let comp: ItemPageFieldComponent; +let fixture: ComponentFixture; + +const mockValue = 'test value'; +const mockField = 'dc.test'; +const mockLabel = 'test label'; +const mockFields = [mockField]; + +describe('ItemPageFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.fields = mockFields; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); + +export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item { + const item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: new MetadataMap() + }); + item.metadata[field] = [{ + language: 'en_US', + value: value + }] as MetadataValue[]; + return item; +} diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts similarity index 82% rename from src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts rename to src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts index f69671a5b5..ce2b110efd 100644 --- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -9,10 +9,13 @@ import { Item } from '../../../../core/shared/item.model'; */ @Component({ - templateUrl: './item-page-specific-field.component.html' + templateUrl: './item-page-field.component.html' }) -export class ItemPageSpecificFieldComponent { +export class ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; /** diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html b/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html deleted file mode 100644 index 4a27848ec6..0000000000 --- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html index 4c53e2e3e2..43bd20d0f6 100644 --- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html @@ -1,3 +1,6 @@

- +
+ {{ type.toLowerCase() + '.page.titleprefix' | translate }} +
+

diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts new file mode 100644 index 0000000000..cb1ba6a4bc --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageTitleFieldComponent } from './item-page-title-field.component'; + +let comp: ItemPageTitleFieldComponent; +let fixture: ComponentFixture; + +const mockField = 'dc.title'; +const mockValue = 'test value'; + +describe('ItemPageTitleFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageTitleFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageTitleFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageTitleFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts index be8102359a..c67d8bcf62 100644 --- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts @@ -1,18 +1,32 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-title-field', templateUrl: './item-page-title-field.component.html' }) -export class ItemPageTitleFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the title (dc.title) of an item + */ +export class ItemPageTitleFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.title' + */ fields: string[] = [ 'dc.title' ]; diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html index fde79d6a04..2b19754127 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html @@ -1,3 +1,3 @@ -
- +
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts new file mode 100644 index 0000000000..4511f16aae --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageUriFieldComponent } from './item-page-uri-field.component'; +import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component'; + +let comp: ItemPageUriFieldComponent; +let fixture: ComponentFixture; + +const mockField = 'dc.identifier.uri'; +const mockValue = 'test value'; + +describe('ItemPageUriFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageUriFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageUriFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts index 4f06337032..c9cd5f1a00 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-uri-field', templateUrl: './item-page-uri-field.component.html' }) -export class ItemPageUriFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the uri (dc.identifier.uri) metadata of an item + */ +export class ItemPageUriFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.identifier.uri' + */ fields: string[] = [ 'dc.identifier.uri' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.uri'; } diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html index 98b98a5e32..b6de496dc4 100644 --- a/src/app/+item-page/simple/item-page.component.html +++ b/src/app/+item-page/simple/item-page.component.html @@ -1,27 +1,7 @@
- -
-
- - - - - - -
- -
+
diff --git a/src/app/+item-page/simple/item-page.component.scss b/src/app/+item-page/simple/item-page.component.scss index 50be6f5ad0..4c26cf08fb 100644 --- a/src/app/+item-page/simple/item-page.component.scss +++ b/src/app/+item-page/simple/item-page.component.scss @@ -1 +1,9 @@ @import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +@include media-breakpoint-down(md) { + .container { + width: 100%; + max-width: none; + } +} diff --git a/src/app/+item-page/simple/item-page.component.spec.ts b/src/app/+item-page/simple/item-page.component.spec.ts new file mode 100644 index 0000000000..e1202ab725 --- /dev/null +++ b/src/app/+item-page/simple/item-page.component.spec.ts @@ -0,0 +1,91 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ItemPageComponent } from './item-page.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { MetadataService } from '../../core/metadata/metadata.service'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { createRelationshipsObservable } from './item-types/shared/item.component.spec'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() +}); + +describe('ItemPageComponent', () => { + let comp: ItemPageComponent; + let fixture: ComponentFixture; + + const mockMetadataService = { + /* tslint:disable:no-empty */ + processRemoteData: () => {} + /* tslint:enable:no-empty */ + }; + const mockRoute = Object.assign(new ActivatedRouteStub(), { + data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) }) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), BrowserAnimationsModule], + declarations: [ItemPageComponent, VarDirective], + providers: [ + {provide: ActivatedRoute, useValue: mockRoute}, + {provide: ItemDataService, useValue: {}}, + {provide: MetadataService, useValue: mockMetadataService}, + {provide: Router, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the item is loading', () => { + beforeEach(() => { + comp.itemRD$ = observableOf(new RemoteData(true, true, true, null, undefined)); + fixture.detectChanges(); + }); + + it('should display a loading component', () => { + const loading = fixture.debugElement.query(By.css('ds-loading')); + expect(loading.nativeElement).toBeDefined(); + }); + }); + + describe('when the item failed loading', () => { + beforeEach(() => { + comp.itemRD$ = observableOf(new RemoteData(false, false, false, null, undefined)); + fixture.detectChanges(); + }); + + it('should display an error component', () => { + const error = fixture.debugElement.query(By.css('ds-error')); + expect(error.nativeElement).toBeDefined(); + }); + }); + +}); diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 35162b011f..89d5977583 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -1,7 +1,7 @@ -import {mergeMap, filter, map} from 'rxjs/operators'; +import { mergeMap, filter, map, take, tap } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Observable } from 'rxjs'; import { ItemDataService } from '../../core/data/item-data.service'; @@ -14,6 +14,8 @@ import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; import { hasValue } from '../../shared/empty.util'; +import { redirectToPageNotFoundOn404 } from '../../core/shared/operators'; +import { ItemViewMode } from '../../shared/items/item-type-decorator'; /** * This component renders a simple item page. @@ -29,28 +31,33 @@ import { hasValue } from '../../shared/empty.util'; }) export class ItemPageComponent implements OnInit { + /** + * The item's id + */ id: number; - private sub: any; - + /** + * The item wrapped in a remote-data object + */ itemRD$: Observable>; - thumbnail$: Observable; + /** + * The view-mode we're currently on + */ + viewMode = ItemViewMode.Full; constructor( private route: ActivatedRoute, + private router: Router, private items: ItemDataService, - private metadataService: MetadataService - ) { - - } + private metadataService: MetadataService, + ) { } ngOnInit(): void { - this.itemRD$ = this.route.data.pipe(map((data) => data.item)); + this.itemRD$ = this.route.data.pipe( + map((data) => data.item as RemoteData), + redirectToPageNotFoundOn404(this.router) + ); this.metadataService.processRemoteData(this.itemRD$); - this.thumbnail$ = this.itemRD$.pipe( - map((rd: RemoteData) => rd.payload), - filter((item: Item) => hasValue(item)), - mergeMap((item: Item) => item.getThumbnail()),); } } diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html new file mode 100644 index 0000000000..5d96abb82b --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html @@ -0,0 +1,50 @@ +

+ {{'journalissue.page.titleprefix' | translate}} +

+
+
+ + + + + + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts new file mode 100644 index 0000000000..24b18af96e --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts @@ -0,0 +1,40 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { JournalIssueComponent } from './journal-issue.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'journalissue.identifier.number': [ + { + language: 'en_US', + value: '1234' + } + ], + 'journalissue.issuedate': [ + { + language: 'en_US', + value: '2018' + } + ], + 'journalissue.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ], + 'journalissue.identifier.keyword': [ + { + language: 'en_US', + value: 'keyword' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('JournalIssueComponent', getItemPageFieldsTest(mockItem, JournalIssueComponent)); diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts new file mode 100644 index 0000000000..77ed54d67f --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts @@ -0,0 +1,51 @@ +import { Component, Inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('JournalIssue', ItemViewMode.Full) +@Component({ + selector: 'ds-journal-issue', + styleUrls: ['./journal-issue.component.scss'], + templateUrl: './journal-issue.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Issue + */ +export class JournalIssueComponent extends ItemComponent { + /** + * The volumes related to this journal issue + */ + volumes$: Observable; + + /** + * The publications related to this journal issue + */ + publications$: Observable; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.volumes$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isJournalVolumeOfIssue'), + relationsToItems(this.item.id, this.ids) + ); + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfJournalIssue'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html new file mode 100644 index 0000000000..18bf1701fc --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html @@ -0,0 +1,37 @@ +

+ {{'journalvolume.page.titleprefix' | translate}} +

+
+
+ + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts new file mode 100644 index 0000000000..a6f32e9b5f --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts @@ -0,0 +1,34 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { JournalVolumeComponent } from './journal-volume.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'journalvolume.identifier.volume': [ + { + language: 'en_US', + value: '1234' + } + ], + 'journalvolume.issuedate': [ + { + language: 'en_US', + value: '2018' + } + ], + 'journalvolume.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('JournalVolumeComponent', getItemPageFieldsTest(mockItem, JournalVolumeComponent)); diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts new file mode 100644 index 0000000000..616d96178a --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts @@ -0,0 +1,51 @@ +import { Component, Inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('JournalVolume', ItemViewMode.Full) +@Component({ + selector: 'ds-journal-volume', + styleUrls: ['./journal-volume.component.scss'], + templateUrl: './journal-volume.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Volume + */ +export class JournalVolumeComponent extends ItemComponent { + /** + * The journals related to this journal volume + */ + journals$: Observable; + + /** + * The journal issues related to this journal volume + */ + issues$: Observable; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.journals$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isJournalOfVolume'), + relationsToItems(this.item.id, this.ids) + ); + this.issues$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isIssueOfJournalVolume'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.html b/src/app/+item-page/simple/item-types/journal/journal.component.html new file mode 100644 index 0000000000..2ab3430256 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.html @@ -0,0 +1,42 @@ +

+ {{'journal.page.titleprefix' | translate}} +

+
+
+ + + + + + + + + +
+ +
+ + +
+
diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.scss b/src/app/+item-page/simple/item-types/journal/journal.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts new file mode 100644 index 0000000000..08e8859b35 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts @@ -0,0 +1,92 @@ +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { JournalComponent } from './journal.component'; +import { of as observableOf } from 'rxjs'; + +let comp: JournalComponent; +let fixture: ComponentFixture; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'journal.identifier.issn': [ + { + language: 'en_US', + value: '1234' + } + ], + 'journal.publisher': [ + { + language: 'en_US', + value: 'a publisher' + } + ], + 'journal.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ] + } +}); + +describe('JournalComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], + providers: [ + {provide: ITEM, useValue: mockItem}, + {provide: ItemDataService, useValue: {}}, + {provide: TruncatableService, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(JournalComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(JournalComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + for (const key of Object.keys(mockItem.metadata)) { + it(`should be calling a component with metadata field ${key}`, () => { + const fields = fixture.debugElement.queryAll(By.css('.item-page-fields')); + expect(containsFieldInput(fields, key)).toBeTruthy(); + }); + } +}); + +function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean { + for (const field of fields) { + const fieldComp = field.componentInstance; + if (isNotEmpty(fieldComp.fields)) { + if (fieldComp.fields.indexOf(metadataKey) > -1) { + return true; + } + } + } + return false; +} diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.ts b/src/app/+item-page/simple/item-types/journal/journal.component.ts new file mode 100644 index 0000000000..0799f5c736 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.ts @@ -0,0 +1,42 @@ +import { Component, Inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Journal', ItemViewMode.Full) +@Component({ + selector: 'ds-journal', + styleUrls: ['./journal.component.scss'], + templateUrl: './journal.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Journal + */ +export class JournalComponent extends ItemComponent { + /** + * The volumes related to this journal + */ + volumes$: Observable; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.volumes$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isVolumeOfJournal'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html new file mode 100644 index 0000000000..0446ac6861 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html @@ -0,0 +1,49 @@ +

+ {{'orgunit.page.titleprefix' | translate}} +

+
+
+ + + + + + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts new file mode 100644 index 0000000000..fa5396fb3d --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts @@ -0,0 +1,46 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { OrgunitComponent } from './orgunit.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'orgunit.identifier.dateestablished': [ + { + language: 'en_US', + value: '2018' + } + ], + 'orgunit.identifier.city': [ + { + language: 'en_US', + value: 'New York' + } + ], + 'orgunit.identifier.country': [ + { + language: 'en_US', + value: 'USA' + } + ], + 'orgunit.identifier.id': [ + { + language: 'en_US', + value: '1' + } + ], + 'orgunit.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('OrgUnitComponent', getItemPageFieldsTest(mockItem, OrgunitComponent)); diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts new file mode 100644 index 0000000000..96dc9a5960 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts @@ -0,0 +1,62 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('OrgUnit', ItemViewMode.Full) +@Component({ + selector: 'ds-orgunit', + styleUrls: ['./orgunit.component.scss'], + templateUrl: './orgunit.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Organisation Unit + */ +export class OrgunitComponent extends ItemComponent implements OnInit { + /** + * The people related to this organisation unit + */ + people$: Observable; + + /** + * The projects related to this organisation unit + */ + projects$: Observable; + + /** + * The publications related to this organisation unit + */ + publications$: Observable; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.people$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPersonOfOrgUnit'), + relationsToItems(this.item.id, this.ids) + ); + + this.projects$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isProjectOfOrgUnit'), + relationsToItems(this.item.id, this.ids) + ); + + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfOrgUnit'), + relationsToItems(this.item.id, this.ids) + ); + } + }} diff --git a/src/app/+item-page/simple/item-types/person/person.component.html b/src/app/+item-page/simple/item-types/person/person.component.html new file mode 100644 index 0000000000..88cd647645 --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.html @@ -0,0 +1,58 @@ +

+ {{'person.page.titleprefix' | translate}} +

+
+
+ + + + + + + + + + + +
+ +
+ + +
+
diff --git a/src/app/+item-page/simple/item-types/person/person.component.scss b/src/app/+item-page/simple/item-types/person/person.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/person/person.component.spec.ts b/src/app/+item-page/simple/item-types/person/person.component.spec.ts new file mode 100644 index 0000000000..cf0d5c197d --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.spec.ts @@ -0,0 +1,58 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { PersonComponent } from './person.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'person.identifier.email': [ + { + language: 'en_US', + value: 'fake@email.com' + } + ], + 'person.identifier.orcid': [ + { + language: 'en_US', + value: 'ORCID-1' + } + ], + 'person.identifier.birthdate': [ + { + language: 'en_US', + value: '1993' + } + ], + 'person.identifier.staffid': [ + { + language: 'en_US', + value: '1' + } + ], + 'person.identifier.jobtitle': [ + { + language: 'en_US', + value: 'Developer' + } + ], + 'person.identifier.lastname': [ + { + language: 'en_US', + value: 'Doe' + } + ], + 'person.identifier.firstname': [ + { + language: 'en_US', + value: 'John' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('PersonComponent', getItemPageFieldsTest(mockItem, PersonComponent)); diff --git a/src/app/+item-page/simple/item-types/person/person.component.ts b/src/app/+item-page/simple/item-types/person/person.component.ts new file mode 100644 index 0000000000..67a2ae7a2e --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.ts @@ -0,0 +1,77 @@ +import { Component, Inject } from '@angular/core'; +import { Observable , of as observableOf } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Person', ItemViewMode.Full) +@Component({ + selector: 'ds-person', + styleUrls: ['./person.component.scss'], + templateUrl: './person.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Person + */ +export class PersonComponent extends ItemComponent { + /** + * The publications related to this person + */ + publications$: Observable; + + /** + * The projects related to this person + */ + projects$: Observable; + + /** + * The organisation units related to this person + */ + orgUnits$: Observable; + + /** + * The applied fixed filter + */ + fixedFilter$: Observable; + + /** + * The query used for applying the fixed filter + */ + fixedFilterQuery: string; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService, + private fixedFilterService: SearchFixedFilterService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfAuthor'), + relationsToItems(this.item.id, this.ids) + ); + + this.projects$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isProjectOfPerson'), + relationsToItems(this.item.id, this.ids) + ); + + this.orgUnits$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isOrgUnitOfPerson'), + relationsToItems(this.item.id, this.ids) + ); + + this.fixedFilterQuery = this.fixedFilterService.getQueryByRelations('isAuthorOfPublication', this.item.id); + this.fixedFilter$ = observableOf('publication'); + } + } +} diff --git a/src/app/+item-page/simple/item-types/project/project.component.html b/src/app/+item-page/simple/item-types/project/project.component.html new file mode 100644 index 0000000000..08e386182b --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.html @@ -0,0 +1,57 @@ +

+ {{'project.page.titleprefix' | translate}} +

+
+
+ + + + + + + + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/project/project.component.scss b/src/app/+item-page/simple/item-types/project/project.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/project/project.component.spec.ts b/src/app/+item-page/simple/item-types/project/project.component.spec.ts new file mode 100644 index 0000000000..9b54ff9a41 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.spec.ts @@ -0,0 +1,46 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { ProjectComponent } from './project.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'project.identifier.status': [ + { + language: 'en_US', + value: 'published' + } + ], + 'project.identifier.id': [ + { + language: 'en_US', + value: '1' + } + ], + 'project.identifier.expectedcompletion': [ + { + language: 'en_US', + value: 'exp comp' + } + ], + 'project.identifier.description': [ + { + language: 'en_US', + value: 'keyword' + } + ], + 'project.identifier.keyword': [ + { + language: 'en_US', + value: 'keyword' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('ProjectComponent', getItemPageFieldsTest(mockItem, ProjectComponent)); diff --git a/src/app/+item-page/simple/item-types/project/project.component.ts b/src/app/+item-page/simple/item-types/project/project.component.ts new file mode 100644 index 0000000000..eafef36307 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.ts @@ -0,0 +1,71 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Project', ItemViewMode.Full) +@Component({ + selector: 'ds-project', + styleUrls: ['./project.component.scss'], + templateUrl: './project.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Project + */ +export class ProjectComponent extends ItemComponent implements OnInit { + /** + * The contributors related to this project + */ + contributors$: Observable; + + /** + * The people related to this project + */ + people$: Observable; + + /** + * The publications related to this project + */ + publications$: Observable; + + /** + * The organisation units related to this project + */ + orgUnits$: Observable; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.contributors$ = this.buildRepresentations('OrgUnit', 'project.contributor.other', this.ids); + + this.people$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPersonOfProject'), + relationsToItems(this.item.id, this.ids) + ); + + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfProject'), + relationsToItems(this.item.id, this.ids) + ); + + this.orgUnits$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isOrgUnitOfProject'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.html b/src/app/+item-page/simple/item-types/publication/publication.component.html new file mode 100644 index 0000000000..37135c6036 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.html @@ -0,0 +1,60 @@ +

+ {{'publication.page.titleprefix' | translate}} +

+
+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + +
+
diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.scss b/src/app/+item-page/simple/item-types/publication/publication.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts new file mode 100644 index 0000000000..48a7a05f45 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts @@ -0,0 +1,90 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { By } from '@angular/platform-browser'; +import { createRelationshipsObservable } from '../shared/item.component.spec'; +import { PublicationComponent } from './publication.component'; +import { of as observableOf } from 'rxjs'; +import { MetadataMap } from '../../../../core/shared/metadata.models'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: new MetadataMap(), + relationships: createRelationshipsObservable() +}); + +describe('PublicationComponent', () => { + let comp: PublicationComponent; + let fixture: ComponentFixture; + + const searchFixedFilterServiceStub = { + /* tslint:disable:no-empty */ + getQueryByRelations: () => {} + /* tslint:enable:no-empty */ + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [PublicationComponent, GenericItemPageFieldComponent, TruncatePipe], + providers: [ + {provide: ITEM, useValue: mockItem}, + {provide: ItemDataService, useValue: {}}, + {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub}, + {provide: TruncatableService, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PublicationComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should contain a component to display the date', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the author', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the abstract', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the uri', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the collections', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + +}); diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.ts b/src/app/+item-page/simple/item-types/publication/publication.component.ts new file mode 100644 index 0000000000..8798b3c1cf --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.ts @@ -0,0 +1,74 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { + DEFAULT_ITEM_TYPE, ItemViewMode, + rendersItemType +} from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { ItemComponent } from '../shared/item.component'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Publication', ItemViewMode.Full) +@rendersItemType(DEFAULT_ITEM_TYPE, ItemViewMode.Full) +@Component({ + selector: 'ds-publication', + styleUrls: ['./publication.component.scss'], + templateUrl: './publication.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PublicationComponent extends ItemComponent implements OnInit { + /** + * The authors related to this publication + */ + authors$: Observable; + + /** + * The projects related to this publication + */ + projects$: Observable; + + /** + * The organisation units related to this publication + */ + orgUnits$: Observable; + + /** + * The journal issues related to this publication + */ + journalIssues$: Observable; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + + ngOnInit(): void { + super.ngOnInit(); + + if (this.resolvedRelsAndTypes$) { + + this.authors$ = this.buildRepresentations('Person', 'dc.contributor.author', this.ids); + + this.projects$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isProjectOfPublication'), + relationsToItems(this.item.id, this.ids) + ); + + this.orgUnits$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isOrgUnitOfPublication'), + relationsToItems(this.item.id, this.ids) + ); + + this.journalIssues$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isJournalIssueOfPublication'), + relationsToItems(this.item.id, this.ids) + ); + + } + } +} diff --git a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts new file mode 100644 index 0000000000..7c632a9365 --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts @@ -0,0 +1,121 @@ +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; +import { getSucceededRemoteData } from '../../../../core/shared/operators'; +import { hasValue } from '../../../../shared/empty.util'; +import { Observable } from 'rxjs/internal/Observable'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { distinctUntilChanged, flatMap, map } from 'rxjs/operators'; +import { of as observableOf, zip as observableZip } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; + +/** + * Operator for comparing arrays using a mapping function + * The mapping function should turn the source array into an array of basic types, so that the array can + * be compared using these basic types. + * For example: "(o) => o.id" will compare the two arrays by comparing their content by id. + * @param mapFn Function for mapping the arrays + */ +export const compareArraysUsing = (mapFn: (t: T) => any) => + (a: T[], b: T[]): boolean => { + if (!Array.isArray(a) || ! Array.isArray(b)) { + return false + } + + const aIds = a.map(mapFn); + const bIds = b.map(mapFn); + + return aIds.length === bIds.length && + aIds.every((e) => bIds.includes(e)) && + bIds.every((e) => aIds.includes(e)); + }; + +/** + * Operator for comparing arrays using the object's ids + */ +export const compareArraysUsingIds = () => + compareArraysUsing((t: T) => hasValue(t) ? t.id : undefined); + +/** + * Fetch the relationships which match the type label given + * @param {string} label Type label + * @returns {(source: Observable<[Relationship[] , RelationshipType[]]>) => Observable} + */ +export const filterRelationsByTypeLabel = (label: string) => + (source: Observable<[Relationship[], RelationshipType[]]>): Observable => + source.pipe( + map(([relsCurrentPage, relTypesCurrentPage]) => + relsCurrentPage.filter((rel: Relationship, idx: number) => + hasValue(relTypesCurrentPage[idx]) && (relTypesCurrentPage[idx].leftLabel === label || + relTypesCurrentPage[idx].rightLabel === label) + ) + ), + distinctUntilChanged(compareArraysUsingIds()) + ); + +/** + * Operator for turning a list of relationships into a list of the relevant items + * @param {string} thisId The item's id of which the relations belong to + * @param {ItemDataService} ids The ItemDataService to fetch items from the REST API + * @returns {(source: Observable) => Observable} + */ +export const relationsToItems = (thisId: string, ids: ItemDataService) => + (source: Observable): Observable => + source.pipe( + flatMap((rels: Relationship[]) => + observableZip( + ...rels.map((rel: Relationship) => { + let queryId = rel.leftId; + if (rel.leftId === thisId) { + queryId = rel.rightId; + } + return ids.findById(queryId); + }) + ) + ), + map((arr: Array>) => + arr + .filter((d: RemoteData) => d.hasSucceeded) + .map((d: RemoteData) => d.payload)), + distinctUntilChanged(compareArraysUsingIds()), + ); + +/** + * Operator for turning a list of relationships into a list of metadatarepresentations given the original metadata + * @param parentId The id of the parent item + * @param itemType The type of relation this list resembles (for creating representations) + * @param metadata The list of original Metadatum objects + * @param ids The ItemDataService to use for fetching Items from the Rest API + */ +export const relationsToRepresentations = (parentId: string, itemType: string, metadata: MetadataValue[], ids: ItemDataService) => + (source: Observable): Observable => + source.pipe( + flatMap((rels: Relationship[]) => + observableZip( + ...metadata + .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) + .map((metadatum: MetadataValue) => { + if (metadatum.isVirtual) { + const matchingRels = rels.filter((rel: Relationship) => ('' + rel.id) === metadatum.virtualValue); + if (matchingRels.length > 0) { + const matchingRel = matchingRels[0]; + let queryId = matchingRel.leftId; + if (matchingRel.leftId === parentId) { + queryId = matchingRel.rightId; + } + return ids.findById(queryId).pipe( + getSucceededRemoteData(), + map((d: RemoteData) => Object.assign(new ItemMetadataRepresentation(), d.payload)) + ); + } + } else { + return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum)); + } + }) + ) + ) + ); diff --git a/src/app/+item-page/simple/item-types/shared/item.component.spec.ts b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts new file mode 100644 index 0000000000..a6b4dd801d --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts @@ -0,0 +1,428 @@ +import { Item } from '../../../../core/shared/item.model'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { ItemComponent } from './item.component'; +import { of as observableOf } from 'rxjs'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { Observable } from 'rxjs/internal/Observable'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models'; +import { compareArraysUsing, compareArraysUsingIds } from './item-relationships-utils'; + +/** + * Create a generic test for an item-page-fields component using a mockItem and the type of component + * @param {Item} mockItem The item to use for testing. The item needs to contain just the metadata necessary to + * execute the tests for it's component. + * @param component The type of component to create test cases for. + * @returns {() => void} Returns a specDefinition for the test. + */ +export function getItemPageFieldsTest(mockItem: Item, component) { + return () => { + let comp: any; + let fixture: ComponentFixture; + + const searchFixedFilterServiceStub = { + /* tslint:disable:no-empty */ + getQueryByRelations: () => {} + /* tslint:enable:no-empty */ + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [component, GenericItemPageFieldComponent, TruncatePipe], + providers: [ + {provide: ITEM, useValue: mockItem}, + {provide: ItemDataService, useValue: {}}, + {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub}, + {provide: TruncatableService, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(component, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(component); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + for (const key of Object.keys(mockItem.metadata)) { + it(`should be calling a component with metadata field ${key}`, () => { + const fields = fixture.debugElement.queryAll(By.css('ds-generic-item-page-field')); + expect(containsFieldInput(fields, key)).toBeTruthy(); + }); + } + } +} + +/** + * Checks whether in a list of debug elements, at least one of them contains a specific metadata key in their + * fields property. + * @param {DebugElement[]} fields List of debug elements to check + * @param {string} metadataKey A metadata key to look for + * @returns {boolean} + */ +export function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean { + for (const field of fields) { + const fieldComp = field.componentInstance; + if (isNotEmpty(fieldComp.fields)) { + if (fieldComp.fields.indexOf(metadataKey) > -1) { + return true; + } + } + } + return false; +} + +export function createRelationshipsObservable() { + return observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [ + Object.assign(new Relationship(), { + relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType())) + }) + ]))); +} +describe('ItemComponent', () => { + const arr1 = [ + { + id: 1, + name: 'test' + }, + { + id: 2, + name: 'another test' + }, + { + id: 3, + name: 'one last test' + } + ]; + const arrWithWrongId = [ + { + id: 1, + name: 'test' + }, + { + id: 5, // Wrong id on purpose + name: 'another test' + }, + { + id: 3, + name: 'one last test' + } + ]; + const arrWithWrongName = [ + { + id: 1, + name: 'test' + }, + { + id: 2, + name: 'wrong test' // Wrong name on purpose + }, + { + id: 3, + name: 'one last test' + } + ]; + const arrWithDifferentOrder = [arr1[0], arr1[2], arr1[1]]; + const arrWithOneMore = [...arr1, { + id: 4, + name: 'fourth test' + }]; + const arrWithAddedProperties = [ + { + id: 1, + name: 'test', + extra: 'extra property' + }, + { + id: 2, + name: 'another test', + extra: 'extra property' + }, + { + id: 3, + name: 'one last test', + extra: 'extra property' + } + ]; + const arrOfPrimitiveTypes = [1, 2, 3, 4]; + const arrOfPrimitiveTypesWithOneWrong = [1, 5, 3, 4]; + const arrOfPrimitiveTypesWithDifferentOrder = [1, 3, 2, 4]; + const arrOfPrimitiveTypesWithOneMore = [1, 2, 3, 4, 5]; + + describe('when calling compareArraysUsing', () => { + + describe('and comparing by id', () => { + const compare = compareArraysUsing((o) => o.id); + + it('should return true when comparing the same array', () => { + expect(compare(arr1, arr1)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy(); + }); + + it('should return true regardless of other properties being different', () => { + expect(compare(arr1, arrWithWrongName)).toBeTruthy(); + }); + + it('should return true regardless of extra properties', () => { + expect(compare(arr1, arrWithAddedProperties)).toBeTruthy(); + }); + + it('should return false when the ids don\'t match', () => { + expect(compare(arr1, arrWithWrongId)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1, arrWithOneMore)).toBeFalsy(); + }); + }); + + describe('and comparing by name', () => { + const compare = compareArraysUsing((o) => o.name); + + it('should return true when comparing the same array', () => { + expect(compare(arr1, arr1)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy(); + }); + + it('should return true regardless of other properties being different', () => { + expect(compare(arr1, arrWithWrongId)).toBeTruthy(); + }); + + it('should return true regardless of extra properties', () => { + expect(compare(arr1, arrWithAddedProperties)).toBeTruthy(); + }); + + it('should return false when the names don\'t match', () => { + expect(compare(arr1, arrWithWrongName)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1, arrWithOneMore)).toBeFalsy(); + }); + }); + + describe('and comparing by full objects', () => { + const compare = compareArraysUsing((o) => o); + + it('should return true when comparing the same array', () => { + expect(compare(arr1, arr1)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy(); + }); + + it('should return false when extra properties are added', () => { + expect(compare(arr1, arrWithAddedProperties)).toBeFalsy(); + }); + + it('should return false when the ids don\'t match', () => { + expect(compare(arr1, arrWithWrongId)).toBeFalsy(); + }); + + it('should return false when the names don\'t match', () => { + expect(compare(arr1, arrWithWrongName)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1, arrWithOneMore)).toBeFalsy(); + }); + }); + + describe('and comparing with primitive objects as source', () => { + const compare = compareArraysUsing((o) => o); + + it('should return true when comparing the same array', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypes)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithDifferentOrder)).toBeTruthy(); + }); + + it('should return false when at least one is wrong', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneWrong)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneMore)).toBeFalsy(); + }); + }); + + }); + + describe('when calling compareArraysUsingIds', () => { + const compare = compareArraysUsingIds(); + + it('should return true when comparing the same array', () => { + expect(compare(arr1 as any, arr1 as any)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1 as any, arrWithDifferentOrder as any)).toBeTruthy(); + }); + + it('should return true regardless of other properties being different', () => { + expect(compare(arr1 as any, arrWithWrongName as any)).toBeTruthy(); + }); + + it('should return true regardless of extra properties', () => { + expect(compare(arr1 as any, arrWithAddedProperties as any)).toBeTruthy(); + }); + + it('should return false when the ids don\'t match', () => { + expect(compare(arr1 as any, arrWithWrongId as any)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1 as any, arrWithOneMore as any)).toBeFalsy(); + }); + }); + + describe('when calling buildRepresentations', () => { + let comp: ItemComponent; + let fixture: ComponentFixture; + + const metadataField = 'dc.contributor.author'; + const mockItem = Object.assign(new Item(), { + id: '1', + uuid: '1', + metadata: new MetadataMap(), + relationships: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [ + Object.assign(new Relationship(), { + uuid: '123', + id: '123', + leftId: '1', + rightId: '2', + relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType())) + }) + ]))) + }); + mockItem.metadata[metadataField] = [ + { + value: 'Second value', + place: 1 + }, + { + value: 'Third value', + place: 2, + authority: 'virtual::123' + }, + { + value: 'First value', + place: 0 + }, + { + value: 'Fourth value', + place: 3, + authority: '123' + } + ] as MetadataValue[]; + const relatedItem = Object.assign(new Item(), { + id: '2', + metadata: Object.assign(new MetadataMap(), { + 'dc.title': [ + { + language: 'en_US', + value: 'related item' + } + ] + }) + }); + const mockItemDataService = Object.assign({ + findById: (id) => { + if (id === relatedItem.id) { + return observableOf(new RemoteData(false, false, true, null, relatedItem)) + } + } + }) as ItemDataService; + + let representations: Observable; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), BrowserAnimationsModule], + declarations: [ItemComponent, VarDirective], + providers: [ + {provide: ITEM, useValue: mockItem} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + representations = comp.buildRepresentations('bogus', metadataField, mockItemDataService); + })); + + it('should contain exactly 4 metadata-representations', () => { + representations.subscribe((reps: MetadataRepresentation[]) => { + expect(reps.length).toEqual(4); + }); + }); + + it('should have all the representations in the correct order', () => { + representations.subscribe((reps: MetadataRepresentation[]) => { + expect(reps[0].getValue()).toEqual('First value'); + expect(reps[1].getValue()).toEqual('Second value'); + expect(reps[2].getValue()).toEqual('related item'); + expect(reps[3].getValue()).toEqual('Fourth value'); + }); + }); + + it('should have created the correct MetadatumRepresentation and ItemMetadataRepresentation objects for the correct Metadata', () => { + representations.subscribe((reps: MetadataRepresentation[]) => { + expect(reps[0] instanceof MetadatumRepresentation).toEqual(true); + expect(reps[1] instanceof MetadatumRepresentation).toEqual(true); + expect(reps[2] instanceof ItemMetadataRepresentation).toEqual(true); + expect(reps[3] instanceof MetadatumRepresentation).toEqual(true); + }); + }); + }) + +}); diff --git a/src/app/+item-page/simple/item-types/shared/item.component.ts b/src/app/+item-page/simple/item-types/shared/item.component.ts new file mode 100644 index 0000000000..c6d43aa6b3 --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item.component.ts @@ -0,0 +1,79 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { combineLatest as observableCombineLatest, Observable, zip as observableZip } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map } from 'rxjs/operators'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { Item } from '../../../../core/shared/item.model'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { compareArraysUsingIds, relationsToRepresentations } from './item-relationships-utils'; + +@Component({ + selector: 'ds-item', + template: '' +}) +/** + * A generic component for displaying metadata and relations of an item + */ +export class ItemComponent implements OnInit { + /** + * Resolved relationships and types together in one observable + */ + resolvedRelsAndTypes$: Observable<[Relationship[], RelationshipType[]]>; + + constructor( + @Inject(ITEM) public item: Item + ) {} + + ngOnInit(): void { + const relationships$ = this.item.relationships; + if (relationships$) { + const relsCurrentPage$ = relationships$.pipe( + filter((rd: RemoteData>) => rd.hasSucceeded), + getRemoteDataPayload(), + map((pl: PaginatedList) => pl.page), + distinctUntilChanged(compareArraysUsingIds()) + ); + + const relTypesCurrentPage$ = relsCurrentPage$.pipe( + flatMap((rels: Relationship[]) => + observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe( + map(([...arr]: Array>) => arr.map((d: RemoteData) => d.payload)) + ) + ), + distinctUntilChanged(compareArraysUsingIds()) + ); + + this.resolvedRelsAndTypes$ = observableCombineLatest( + relsCurrentPage$, + relTypesCurrentPage$ + ); + } + } + + /** + * Build a list of MetadataRepresentations for the current item. This combines all metadata and relationships of a + * certain type. + * @param itemType The type of item we're building representations of. Used for matching templates. + * @param metadataField The metadata field that resembles the item type. + * @param itemDataService ItemDataService to turn relations into items. + */ + buildRepresentations(itemType: string, metadataField: string, itemDataService: ItemDataService): Observable { + const metadata = this.item.findMetadataSortedByPlace(metadataField); + const relsCurrentPage$ = this.item.relationships.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((pl: PaginatedList) => pl.page), + distinctUntilChanged(compareArraysUsingIds()) + ); + + return relsCurrentPage$.pipe( + relationsToRepresentations(this.item.id, itemType, metadata, itemDataService) + ); + } + +} diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html new file mode 100644 index 0000000000..48eabf8451 --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html @@ -0,0 +1,5 @@ + + + + diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts new file mode 100644 index 0000000000..f02625e8c7 --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts @@ -0,0 +1,40 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { MetadataRepresentationListComponent } from './metadata-representation-list.component'; +import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; + +const itemType = 'type'; +const metadataRepresentation1 = new MetadatumRepresentation(itemType); +const metadataRepresentation2 = new ItemMetadataRepresentation(); +const representations = [metadataRepresentation1, metadataRepresentation2]; + +describe('MetadataRepresentationListComponent', () => { + let comp: MetadataRepresentationListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [MetadataRepresentationListComponent], + providers: [], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataRepresentationListComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(MetadataRepresentationListComponent); + comp = fixture.componentInstance; + comp.representations = representations; + fixture.detectChanges(); + })); + + it(`should load ${representations.length} item-type-switcher components`, () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher')); + expect(fields.length).toBe(representations.length); + }); + +}); diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts new file mode 100644 index 0000000000..f0dc222bf1 --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -0,0 +1,29 @@ +import { Component, Input } from '@angular/core'; +import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; +import { ItemViewMode } from '../../../shared/items/item-type-decorator'; + +@Component({ + selector: 'ds-metadata-representation-list', + templateUrl: './metadata-representation-list.component.html' +}) +/** + * This component is used for displaying metadata + * It expects a list of MetadataRepresentation objects and a label to put on top of the list + */ +export class MetadataRepresentationListComponent { + /** + * A list of metadata-representations to display + */ + @Input() representations: MetadataRepresentation[]; + + /** + * An i18n label to use as a title for the list + */ + @Input() label: string; + + /** + * The view-mode we're currently on + * @type {ElementViewMode} + */ + viewMode = ItemViewMode.Metadata; +} diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html new file mode 100644 index 0000000000..9ec082db73 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts new file mode 100644 index 0000000000..e76a9cf3d0 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts @@ -0,0 +1,56 @@ +import { RelatedEntitiesSearchComponent } from './related-entities-search.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { Item } from '../../../../core/shared/item.model'; + +describe('RelatedEntitiesSearchComponent', () => { + let comp: RelatedEntitiesSearchComponent; + let fixture: ComponentFixture; + let fixedFilterService: SearchFixedFilterService; + + const mockItem = Object.assign(new Item(), { + id: 'id1' + }); + const mockRelationType = 'publicationsOfAuthor'; + const mockRelationEntityType = 'publication'; + const mockFilter= `f.${mockRelationType}=${mockItem.id}`; + const fixedFilterServiceStub = { + getFilterByRelation: () => mockFilter + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [RelatedEntitiesSearchComponent], + providers: [ + { provide: SearchFixedFilterService, useValue: fixedFilterServiceStub } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(RelatedEntitiesSearchComponent); + comp = fixture.componentInstance; + fixedFilterService = (comp as any).fixedFilterService; + comp.relationType = mockRelationType; + comp.item = mockItem; + comp.relationEntityType = mockRelationEntityType; + fixture.detectChanges(); + }); + + it('should create a fixedFilter', () => { + expect(comp.fixedFilter).toEqual(mockFilter); + }); + + it('should create a fixedFilter$', () => { + comp.fixedFilter$.subscribe((fixedFilter) => { + expect(fixedFilter).toEqual(mockRelationEntityType); + }) + }); + +}); diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts new file mode 100644 index 0000000000..672655a8b8 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts @@ -0,0 +1,64 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Item } from '../../../../core/shared/item.model'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { of } from 'rxjs/internal/observable/of'; + +@Component({ + selector: 'ds-related-entities-search', + templateUrl: './related-entities-search.component.html' +}) +/** + * A component to show related items as search results. + * Related items can be facetted, or queried using an + * optional search box. + */ +export class RelatedEntitiesSearchComponent implements OnInit { + + /** + * The type of relationship to fetch items for + * e.g. 'isAuthorOfPublication' + */ + @Input() relationType: string; + + /** + * The item to render relationships for + */ + @Input() item: Item; + + /** + * The entity type of the relationship items to be displayed + * e.g. 'publication' + * This determines the title of the search results (if search is enabled) + */ + @Input() relationEntityType: string; + + /** + * Whether or not the search bar and title should be displayed (defaults to true) + * @type {boolean} + */ + @Input() searchEnabled = true; + + /** + * The ratio of the sidebar's width compared to the search results (1-12) (defaults to 4) + * @type {number} + */ + @Input() sideBarWidth = 4; + + fixedFilter: string; + fixedFilter$: Observable; + + constructor(private fixedFilterService: SearchFixedFilterService) { + } + + ngOnInit(): void { + if (isNotEmpty(this.relationType) && isNotEmpty(this.item)) { + this.fixedFilter = this.fixedFilterService.getFilterByRelation(this.relationType, this.item.id); + } + if (isNotEmpty(this.relationEntityType)) { + this.fixedFilter$ = of(this.relationEntityType); + } + } + +} diff --git a/src/app/+item-page/simple/related-items/related-items-component.ts b/src/app/+item-page/simple/related-items/related-items-component.ts new file mode 100644 index 0000000000..7b54d7316a --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items-component.ts @@ -0,0 +1,30 @@ +import { Component, Input } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ItemViewMode } from '../../../shared/items/item-type-decorator'; + +@Component({ + selector: 'ds-related-items', + styleUrls: ['./related-items.component.scss'], + templateUrl: './related-items.component.html' +}) +/** + * This component is used for displaying relations between items + * It expects a list of items to display and a label to put on top + */ +export class RelatedItemsComponent { + /** + * A list of items to display + */ + @Input() items: Item[]; + + /** + * An i18n label to use as a title for the list (usually describes the relation) + */ + @Input() label: string; + + /** + * The view-mode we're currently on + * @type {ElementViewMode} + */ + viewMode = ItemViewMode.Element; +} diff --git a/src/app/+item-page/simple/related-items/related-items.component.html b/src/app/+item-page/simple/related-items/related-items.component.html new file mode 100644 index 0000000000..4b284ad63c --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items.component.html @@ -0,0 +1,5 @@ + + + + diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss b/src/app/+item-page/simple/related-items/related-items.component.scss similarity index 100% rename from src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss rename to src/app/+item-page/simple/related-items/related-items.component.scss diff --git a/src/app/+item-page/simple/related-items/related-items.component.spec.ts b/src/app/+item-page/simple/related-items/related-items.component.spec.ts new file mode 100644 index 0000000000..ef42ab1098 --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items.component.spec.ts @@ -0,0 +1,51 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { RelatedItemsComponent } from './related-items-component'; +import { Item } from '../../../core/shared/item.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { By } from '@angular/platform-browser'; +import { createRelationshipsObservable } from '../item-types/shared/item.component.spec'; +import { of as observableOf } from 'rxjs'; + +const mockItem1: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() +}); +const mockItem2: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() +}); +const mockItems = [mockItem1, mockItem2]; + +describe('RelatedItemsComponent', () => { + let comp: RelatedItemsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [RelatedItemsComponent], + providers: [], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(RelatedItemsComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(RelatedItemsComponent); + comp = fixture.componentInstance; + comp.items = mockItems; + fixture.detectChanges(); + })); + + it(`should load ${mockItems.length} item-type-switcher components`, () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher')); + expect(fields.length).toBe(mockItems.length); + }); + +}); diff --git a/src/app/+login-page/login-page-routing.module.ts b/src/app/+login-page/login-page-routing.module.ts index 4e932c50ce..d3c6425dd3 100644 --- a/src/app/+login-page/login-page-routing.module.ts +++ b/src/app/+login-page/login-page-routing.module.ts @@ -6,7 +6,7 @@ import { LoginPageComponent } from './login-page.component'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', component: LoginPageComponent, data: { title: 'login.title' } } + { path: '', pathMatch: 'full', component: LoginPageComponent, data: { title: 'login.title' } } ]) ] }) diff --git a/src/app/+login-page/login-page.component.spec.ts b/src/app/+login-page/login-page.component.spec.ts index 234435a410..74ce5d4f9a 100644 --- a/src/app/+login-page/login-page.component.spec.ts +++ b/src/app/+login-page/login-page.component.spec.ts @@ -1,15 +1,20 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { LoginPageComponent } from './login-page.component'; +import { ActivatedRouteStub } from '../shared/testing/active-router-stub'; describe('LoginPageComponent', () => { let comp: LoginPageComponent; let fixture: ComponentFixture; + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}) + }); const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ @@ -25,9 +30,8 @@ describe('LoginPageComponent', () => { ], declarations: [LoginPageComponent], providers: [ - { - provide: Store, useValue: store - } + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Store, useValue: store } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+login-page/login-page.component.ts b/src/app/+login-page/login-page.component.ts index 2752973130..6a8508eb45 100644 --- a/src/app/+login-page/login-page.component.ts +++ b/src/app/+login-page/login-page.component.ts @@ -1,20 +1,81 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { combineLatest as observableCombineLatest, Subscription } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { AppState } from '../app.reducer'; -import { ResetAuthenticationMessagesAction } from '../core/auth/auth.actions'; +import { + AddAuthenticationMessageAction, + AuthenticatedAction, + AuthenticationSuccessAction, + ResetAuthenticationMessagesAction +} from '../core/auth/auth.actions'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; +import { isAuthenticated } from '../core/auth/selectors'; +/** + * This component represents the login page + */ @Component({ selector: 'ds-login-page', styleUrls: ['./login-page.component.scss'], templateUrl: './login-page.component.html' }) -export class LoginPageComponent implements OnDestroy { +export class LoginPageComponent implements OnDestroy, OnInit { - constructor(private store: Store) {} + /** + * Subscription to unsubscribe onDestroy + * @type {Subscription} + */ + sub: Subscription; + /** + * Initialize instance variables + * + * @param {ActivatedRoute} route + * @param {Store} store + */ + constructor(private route: ActivatedRoute, + private store: Store) {} + + /** + * Initialize instance variables + */ + ngOnInit() { + const queryParamsObs = this.route.queryParams; + const authenticated = this.store.select(isAuthenticated); + this.sub = observableCombineLatest(queryParamsObs, authenticated).pipe( + filter(([params, auth]) => isNotEmpty(params.token) || isNotEmpty(params.expired)), + take(1) + ).subscribe(([params, auth]) => { + const token = params.token; + let authToken: AuthTokenInfo; + if (!auth) { + if (isNotEmpty(token)) { + authToken = new AuthTokenInfo(token); + this.store.dispatch(new AuthenticatedAction(authToken)); + } else if (isNotEmpty(params.expired)) { + this.store.dispatch(new AddAuthenticationMessageAction('auth.messages.expired')); + } + } else { + if (isNotEmpty(token)) { + authToken = new AuthTokenInfo(token); + this.store.dispatch(new AuthenticationSuccessAction(authToken)); + } + } + }) + } + + /** + * Unsubscribe from subscription + */ ngOnDestroy() { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } // Clear all authentication messages when leaving login page this.store.dispatch(new ResetAuthenticationMessagesAction()); } diff --git a/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts new file mode 100644 index 0000000000..baf2f0b920 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts @@ -0,0 +1,4 @@ +export enum MyDSpaceConfigurationValueType { + Workspace = 'workspace', + Workflow = 'workflow' +} diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts new file mode 100644 index 0000000000..38d6769437 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts @@ -0,0 +1,259 @@ +import { of as observableOf } from 'rxjs'; + +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { SearchFilter } from '../+search-page/search-filter.model'; +import { ActivatedRouteStub } from '../shared/testing/active-router-stub'; +import { MockRoleService } from '../shared/mocks/mock-role-service'; +import { cold, hot } from 'jasmine-marbles'; +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; + +describe('MyDSpaceConfigurationService', () => { + let service: MyDSpaceConfigurationService; + const value1 = 'random value'; + const prefixFilter = { + 'f.namedresourcetype': ['another value'], + 'f.dateSubmitted.min': ['2013'], + 'f.dateSubmitted.max': ['2018'] + }; + const defaults = new PaginatedSearchOptions({ + pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }), + sort: new SortOptions('score', SortDirection.DESC), + query: '', + scope: '' + }); + + const backendFilters = [new SearchFilter('f.namedresourcetype', ['another value']), new SearchFilter('f.dateSubmitted', ['[2013 TO 2018]'])]; + + const spy = jasmine.createSpyObj('RouteService', { + getQueryParameterValue: observableOf(value1), + getQueryParamsWithPrefix: observableOf(prefixFilter), + getRouteParameterValue: observableOf(''), + getRouteDataValue: observableOf({}) + }); + + const activatedRoute: any = new ActivatedRouteStub(); + + const roleService: any = new MockRoleService(); + + const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { + getQueryByFilterName: observableOf(''), + }); + + beforeEach(() => { + service = new MyDSpaceConfigurationService(roleService, fixedFilterService, spy, activatedRoute); + }); + + describe('when the scope is called', () => { + beforeEach(() => { + service.getCurrentScope(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'scope\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('scope'); + }); + }); + + describe('when getCurrentConfiguration is called', () => { + beforeEach(() => { + service.getCurrentConfiguration(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'configuration\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('configuration'); + }); + }); + + describe('when getCurrentQuery is called', () => { + beforeEach(() => { + service.getCurrentQuery(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'query\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('query'); + }); + }); + + describe('when getCurrentDSOType is called', () => { + beforeEach(() => { + service.getCurrentDSOType(); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'dsoType\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('dsoType'); + }); + }); + + describe('when getCurrentFrontendFilters is called', () => { + beforeEach(() => { + service.getCurrentFrontendFilters(); + }); + it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => { + expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + }); + }); + + describe('when getCurrentFilters is called', () => { + let parsedValues$; + beforeEach(() => { + parsedValues$ = service.getCurrentFilters(); + }); + it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => { + expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + parsedValues$.subscribe((values) => { + expect(values).toEqual(backendFilters); + }); + }); + }); + + describe('when getCurrentSort is called', () => { + beforeEach(() => { + service.getCurrentSort({} as any); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'sortDirection\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortDirection'); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'sortField\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField'); + }); + }); + + describe('when getCurrentPagination is called', () => { + beforeEach(() => { + service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'page\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('page'); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'pageSize\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize'); + }); + }); + + describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => { + beforeEach(() => { + spyOn(service, 'getCurrentPagination').and.callThrough(); + spyOn(service, 'getCurrentSort').and.callThrough(); + spyOn(service, 'getCurrentScope').and.callThrough(); + spyOn(service, 'getCurrentConfiguration').and.callThrough(); + spyOn(service, 'getCurrentQuery').and.callThrough(); + spyOn(service, 'getCurrentDSOType').and.callThrough(); + spyOn(service, 'getCurrentFilters').and.callThrough(); + }); + + describe('when subscribeToSearchOptions is called', () => { + beforeEach(() => { + (service as any).subscribeToSearchOptions(defaults) + }); + it('should call all getters it needs, but not call any others', () => { + expect(service.getCurrentPagination).not.toHaveBeenCalled(); + expect(service.getCurrentSort).not.toHaveBeenCalled(); + expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); + expect(service.getCurrentQuery).toHaveBeenCalled(); + expect(service.getCurrentDSOType).toHaveBeenCalled(); + expect(service.getCurrentFilters).toHaveBeenCalled(); + }); + }); + + describe('when subscribeToPaginatedSearchOptions is called', () => { + beforeEach(() => { + (service as any).subscribeToPaginatedSearchOptions(defaults); + }); + it('should call all getters it needs', () => { + expect(service.getCurrentPagination).toHaveBeenCalled(); + expect(service.getCurrentSort).toHaveBeenCalled(); + expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); + expect(service.getCurrentQuery).toHaveBeenCalled(); + expect(service.getCurrentDSOType).toHaveBeenCalled(); + expect(service.getCurrentFilters).toHaveBeenCalled(); + }); + }); + }); + + describe('when getAvailableConfigurationTypes is called', () => { + + it('should return properly list when user is submitter', () => { + roleService.setSubmitter(true); + roleService.setController(false); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workspace + ] + })); + }); + + it('should return properly list when user is controller', () => { + roleService.setSubmitter(false); + roleService.setController(true); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + + it('should return properly list when user is admin', () => { + roleService.setSubmitter(false); + roleService.setController(false); + roleService.setAdmin(true); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + + it('should return properly list when user is submitter and controller', () => { + roleService.setSubmitter(true); + roleService.setController(true); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + }); + + describe('when getAvailableConfigurationOptions is called', () => { + + it('should return properly options list', () => { + spyOn(service, 'getAvailableConfigurationTypes').and.returnValue(hot('a', { + a: [ + MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.Workflow + ] + })); + + const list$ = service.getAvailableConfigurationOptions(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + { + value: MyDSpaceConfigurationValueType.Workspace, + label: `mydspace.show.${MyDSpaceConfigurationValueType.Workspace}` + }, + { + value: MyDSpaceConfigurationValueType.Workflow, + label: `mydspace.show.${MyDSpaceConfigurationValueType.Workflow}` + } + ] + })); + }); + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.ts new file mode 100644 index 0000000000..705ec897f8 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { combineLatest, Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; +import { RoleService } from '../core/roles/role.service'; +import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { RouteService } from '../shared/services/route.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; + +/** + * Service that performs all actions that have to do with the current mydspace configuration + */ +@Injectable() +export class MyDSpaceConfigurationService extends SearchConfigurationService { + /** + * Default pagination settings + */ + protected defaultPagination = Object.assign(new PaginationComponentOptions(), { + id: 'mydspace-page', + pageSize: 10, + currentPage: 1 + }); + + /** + * Default sort settings + */ + protected defaultSort = new SortOptions('dc.date.issued', SortDirection.DESC); + + /** + * Default configuration parameter setting + */ + protected defaultConfiguration = 'workspace'; + + /** + * Default scope setting + */ + protected defaultScope = ''; + + /** + * Default query setting + */ + protected defaultQuery = ''; + + private isAdmin$: Observable; + private isController$: Observable; + private isSubmitter$: Observable; + + /** + * Initialize class + * + * @param {roleService} roleService + * @param {SearchFixedFilterService} fixedFilterService + * @param {RouteService} routeService + * @param {ActivatedRoute} route + */ + constructor(protected roleService: RoleService, + protected fixedFilterService: SearchFixedFilterService, + protected routeService: RouteService, + protected route: ActivatedRoute) { + + super(routeService, fixedFilterService, route); + + // override parent class initialization + this._defaults = null; + this.initDefaults(); + + this.isSubmitter$ = this.roleService.isSubmitter(); + this.isController$ = this.roleService.isController(); + this.isAdmin$ = this.roleService.isAdmin(); + } + + /** + * Returns the list of available configuration depend on the user role + * + * @return {Observable} + * Emits the available configuration list + */ + public getAvailableConfigurationTypes(): Observable { + return combineLatest(this.isSubmitter$, this.isController$, this.isAdmin$).pipe( + first(), + map(([isSubmitter, isController, isAdmin]: [boolean, boolean, boolean]) => { + const availableConf: MyDSpaceConfigurationValueType[] = []; + if (isSubmitter) { + availableConf.push(MyDSpaceConfigurationValueType.Workspace); + } + if (isController || isAdmin) { + availableConf.push(MyDSpaceConfigurationValueType.Workflow); + } + return availableConf; + })); + } + + /** + * Returns the select options for the available configuration list + * + * @return {Observable} + * Emits the select options list + */ + public getAvailableConfigurationOptions(): Observable { + return this.getAvailableConfigurationTypes().pipe( + first(), + map((availableConfigurationTypes: MyDSpaceConfigurationValueType[]) => { + const configurationOptions: SearchConfigurationOption[] = []; + availableConfigurationTypes.forEach((type) => { + const value = type; + const label = `mydspace.show.${value}`; + configurationOptions.push({ value, label }); + }); + return configurationOptions; + }) + ) + } + +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html new file mode 100644 index 0000000000..280d694d27 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -0,0 +1,15 @@ + diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss new file mode 100644 index 0000000000..40a955b349 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss @@ -0,0 +1,11 @@ +.parent { + display: flex; +} + +.upload { + flex: auto; +} + +.add { + flex: initial; +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts new file mode 100644 index 0000000000..012f86f579 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -0,0 +1,101 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { Store } from '@ngrx/store'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; + +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +import { AuthService } from '../../core/auth/auth.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { createTestComponent } from '../../shared/testing/utils'; +import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission.component'; +import { AppState } from '../../app.reducer'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { SharedModule } from '../../shared/shared.module'; +import { getMockScrollToService } from '../../shared/mocks/mock-scroll-to-service'; +import { UploaderService } from '../../shared/uploader/uploader.service'; + +describe('MyDSpaceNewSubmissionComponent test', () => { + + const translateService: any = getMockTranslateService(); + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + pipe: observableOf(true) + }); + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + SharedModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ + MyDSpaceNewSubmissionComponent, + TestComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: ScrollToService, useValue: getMockScrollToService() }, + { provide: Store, useValue: store }, + { provide: TranslateService, useValue: translateService }, + ChangeDetectorRef, + MyDSpaceNewSubmissionComponent, + UploaderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create MyDSpaceNewSubmissionComponent', inject([MyDSpaceNewSubmissionComponent], (app: MyDSpaceNewSubmissionComponent) => { + + expect(app).toBeDefined(); + + })); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + reload = (event) => { + return; + } +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts new file mode 100644 index 0000000000..938a1ec899 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -0,0 +1,118 @@ +import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; + +import { Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; + +import { SubmissionState } from '../../submission/submission.reducers'; +import { AuthService } from '../../core/auth/auth.service'; +import { MyDSpaceResult } from '../my-dspace-result.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { NotificationType } from '../../shared/notifications/models/notification-type'; +import { hasValue } from '../../shared/empty.util'; + +/** + * This component represents the whole mydspace page header + */ +@Component({ + selector: 'ds-my-dspace-new-submission', + styleUrls: ['./my-dspace-new-submission.component.scss'], + templateUrl: './my-dspace-new-submission.component.html' +}) +export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { + @Output() uploadEnd = new EventEmitter>>(); + + /** + * The UploaderOptions object + */ + public uploadFilesOptions: UploaderOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + + /** + * Subscription to unsubscribe from + */ + private sub: Subscription; + + /** + * Initialize instance variables + * + * @param {AuthService} authService + * @param {ChangeDetectorRef} changeDetectorRef + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {Store} store + * @param {TranslateService} translate + */ + constructor(private authService: AuthService, + private changeDetectorRef: ChangeDetectorRef, + private halService: HALEndpointService, + private notificationsService: NotificationsService, + private store: Store, + private translate: TranslateService) { + } + + /** + * Initialize url and Bearer token + */ + ngOnInit() { + this.sub = this.halService.getEndpoint('workspaceitems').pipe(first()).subscribe((url) => { + this.uploadFilesOptions.url = url; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + this.changeDetectorRef.detectChanges(); + } + ); + } + + /** + * Method called when file upload is completed to notify upload status + */ + public onCompleteItem(res) { + if (res && res._embedded && res._embedded.workspaceitems && res._embedded.workspaceitems.length > 0) { + const workspaceitems = res._embedded.workspaceitems; + this.uploadEnd.emit(workspaceitems); + + if (workspaceitems.length === 1) { + const options = new NotificationOptions(); + options.timeOut = 0; + const link = '/workspaceitems/' + workspaceitems[0].id + '/edit'; + this.notificationsService.notificationWithAnchor( + NotificationType.Success, + options, + link, + 'mydspace.general.text-here', + 'mydspace.upload.upload-successful', + 'here'); + } else if (workspaceitems.length > 1) { + this.notificationsService.success(null, this.translate.get('mydspace.upload.upload-multiple-successful', {qty: workspaceitems.length})); + } + + } else { + this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); + } + } + + /** + * Method called on file upload error + */ + public onUploadError() { + this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+my-dspace-page/my-dspace-page-routing.module.ts b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts new file mode 100644 index 0000000000..d70a007e3a --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { MyDSpacePageComponent } from './my-dspace-page.component'; +import { MyDSpaceGuard } from './my-dspace.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: MyDSpacePageComponent, + data: { title: 'mydspace.title' }, + canActivate: [ + MyDSpaceGuard + ] + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the mydspace page path. + */ +export class MyDspacePageRoutingModule { +} diff --git a/src/app/+my-dspace-page/my-dspace-page.component.html b/src/app/+my-dspace-page/my-dspace-page.component.html new file mode 100644 index 0000000000..4c691028fc --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.html @@ -0,0 +1,48 @@ +
+ +
+ +
+ + + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.scss b/src/app/+my-dspace-page/my-dspace-page.component.scss new file mode 100644 index 0000000000..86c589bf66 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.scss @@ -0,0 +1 @@ +@import '../+search-page/search-page.component.scss'; diff --git a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts new file mode 100644 index 0000000000..9658814a6a --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts @@ -0,0 +1,204 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; + +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { HostWindowService } from '../shared/host-window.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { RemoteData } from '../core/data/remote-data'; +import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.component'; +import { RouteService } from '../shared/services/route.service'; +import { routeServiceStub } from '../shared/testing/route-service-stub'; +import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; +import { SearchService } from '../+search-page/search-service/search.service'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service'; +import { SearchFilterService } from '../+search-page/search-filters/search-filter/search-filter.service'; +import { RoleDirective } from '../shared/roles/role.directive'; +import { RoleService } from '../core/roles/role.service'; +import { MockRoleService } from '../shared/mocks/mock-role-service'; +import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; + +describe('MyDSpacePageComponent', () => { + let comp: MyDSpacePageComponent; + let fixture: ComponentFixture; + let searchServiceObject: SearchService; + let searchConfigurationServiceObject: SearchConfigurationService; + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + select: observableOf(true) + }); + const pagination: PaginationComponentOptions = new PaginationComponentOptions(); + pagination.id = 'mydspace-results-pagination'; + pagination.currentPage = 1; + pagination.pageSize = 10; + const sort: SortOptions = new SortOptions('score', SortDirection.DESC); + const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); + const searchServiceStub = jasmine.createSpyObj('SearchService', { + search: mockResults, + getSearchLink: '/mydspace', + getScopes: observableOf(['test-scope']), + setServiceOptions: {} + }); + const configurationParam = 'default'; + const queryParam = 'test query'; + const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; + const paginatedSearchOptions = new PaginatedSearchOptions({ + configuration: configurationParam, + query: queryParam, + scope: scopeParam, + pagination, + sort + }); + const activatedRouteStub = { + snapshot: { + queryParamMap: new Map([ + ['query', queryParam], + ['scope', scopeParam] + ]) + }, + queryParams: observableOf({ + query: queryParam, + scope: scopeParam + }) + }; + const sidebarService = { + isCollapsed: observableOf(true), + collapse: () => this.isCollapsed = observableOf(true), + expand: () => this.isCollapsed = observableOf(false) + }; + const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } + } as SearchFixedFilterService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()], + declarations: [MyDSpacePageComponent, RoleDirective], + providers: [ + { provide: SearchService, useValue: searchServiceStub }, + { + provide: CommunityDataService, + useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll']) + }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: RouteService, useValue: routeServiceStub }, + { + provide: Store, useValue: store + }, + { + provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', + { + isXs: observableOf(true), + isSm: observableOf(false), + isXsOrSm: observableOf(true) + }) + }, + { + provide: SearchSidebarService, + useValue: sidebarService + }, + { + provide: SearchFilterService, + useValue: {} + }, { + provide: SEARCH_CONFIG_SERVICE, + useValue: new SearchConfigurationServiceStub() + }, + { + provide: RoleService, + useValue: new MockRoleService() + }, + { + provide: SearchFixedFilterService, + useValue: mockFixedFilterService + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MyDSpacePageComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpacePageComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + fixture.detectChanges(); + searchServiceObject = (comp as any).service; + searchConfigurationServiceObject = (comp as any).searchConfigService; + }); + + afterEach(() => { + comp = null; + searchServiceObject = null; + searchConfigurationServiceObject = null; + }); + + it('should get the scope and query from the route parameters', () => { + + searchConfigurationServiceObject.paginatedSearchOptions.next(paginatedSearchOptions); + expect(comp.searchOptions$).toBeObservable(cold('b', { + b: paginatedSearchOptions + })); + + }); + + describe('when the open sidebar button is clicked in mobile view', () => { + + beforeEach(() => { + spyOn(comp, 'openSidebar'); + const openSidebarButton = fixture.debugElement.query(By.css('.open-sidebar')); + openSidebarButton.triggerEventHandler('click', null); + }); + + it('should trigger the openSidebar function', () => { + expect(comp.openSidebar).toHaveBeenCalled(); + }); + + }); + + describe('when sidebarCollapsed is true in mobile view', () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; + comp.isSidebarCollapsed = () => observableOf(true); + fixture.detectChanges(); + }); + + it('should close the sidebar', () => { + expect(menu.classList).not.toContain('active'); + }); + + }); + + describe('when sidebarCollapsed is false in mobile view', () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; + comp.isSidebarCollapsed = () => observableOf(false); + fixture.detectChanges(); + }); + + it('should open the menu', () => { + expect(menu.classList).toContain('active'); + }); + + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-page.component.ts b/src/app/+my-dspace-page/my-dspace-page.component.ts new file mode 100644 index 0000000000..251bf50bd1 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectionStrategy, + Component, + Inject, + InjectionToken, + Input, + OnInit +} from '@angular/core'; + +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { switchMap, tap, } from 'rxjs/operators'; + +import { PaginatedList } from '../core/data/paginated-list'; +import { RemoteData } from '../core/data/remote-data'; +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { pushInOut } from '../shared/animations/push'; +import { HostWindowService } from '../shared/host-window.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchService } from '../+search-page/search-service/search.service'; +import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service'; +import { hasValue } from '../shared/empty.util'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { MyDSpaceResult } from './my-dspace-result.model'; +import { MyDSpaceResponseParsingService } from '../core/data/mydspace-response-parsing.service'; +import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; +import { RoleType } from '../core/roles/role-types'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; +import { ViewMode } from '../core/shared/view-mode.model'; +import { MyDSpaceRequest } from '../core/data/request.models'; + +export const MYDSPACE_ROUTE = '/mydspace'; +export const SEARCH_CONFIG_SERVICE: InjectionToken = new InjectionToken('searchConfigurationService'); + +/** + * This component represents the whole mydspace page + */ +@Component({ + selector: 'ds-my-dspace-page', + styleUrls: ['./my-dspace-page.component.scss'], + templateUrl: './my-dspace-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: MyDSpaceConfigurationService + } + ] +}) +export class MyDSpacePageComponent implements OnInit { + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch = true; + + /** + * The list of available configuration options + */ + configurationList$: Observable; + + /** + * The current search results + */ + resultsRD$: BehaviorSubject>>> = new BehaviorSubject(null); + + /** + * The current paginated search options + */ + searchOptions$: Observable; + + /** + * The current relevant scopes + */ + scopeListRD$: Observable; + + /** + * Emits true if were on a small screen + */ + isXsOrSm$: Observable; + + /** + * Subscription to unsubscribe from + */ + sub: Subscription; + + /** + * Variable for enumeration RoleType + */ + roleTypeEnum = RoleType; + + /** + * List of available view mode + */ + viewModeList = [ViewMode.List, ViewMode.Detail]; + + constructor(private service: SearchService, + private sidebarService: SearchSidebarService, + private windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: MyDSpaceConfigurationService) { + this.isXsOrSm$ = this.windowService.isXsOrSm(); + this.service.setServiceOptions(MyDSpaceResponseParsingService, MyDSpaceRequest); + } + + /** + * Initialize available configuration list + * + * Listening to changes in the paginated search options + * If something changes, update the search results + * + * Listen to changes in the scope + * If something changes, update the list of scopes for the dropdown + */ + ngOnInit(): void { + this.configurationList$ = this.searchConfigService.getAvailableConfigurationOptions(); + this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; + + this.sub = this.searchOptions$.pipe( + tap(() => this.resultsRD$.next(null)), + switchMap((options: PaginatedSearchOptions) => this.service.search(options).pipe(getSucceededRemoteData()))) + .subscribe((results) => { + this.resultsRD$.next(results); + }); + this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( + switchMap((scopeId) => this.service.getScopes(scopeId)) + ); + + } + + /** + * Set the sidebar to a collapsed state + */ + public closeSidebar(): void { + this.sidebarService.collapse() + } + + /** + * Set the sidebar to an expanded state + */ + public openSidebar(): void { + this.sidebarService.expand(); + } + + /** + * Check if the sidebar is collapsed + * @returns {Observable} emits true if the sidebar is currently collapsed, false if it is expanded + */ + public isSidebarCollapsed(): Observable { + return this.sidebarService.isCollapsed; + } + + /** + * @returns {string} The base path to the search page + */ + public getSearchLink(): string { + return this.service.getSearchLink(); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+my-dspace-page/my-dspace-page.module.ts b/src/app/+my-dspace-page/my-dspace-page.module.ts new file mode 100644 index 0000000000..4b8cf37b7a --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.module.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; + +import { MyDspacePageRoutingModule } from './my-dspace-page-routing.module'; +import { MyDSpacePageComponent } from './my-dspace-page.component'; +import { SearchPageModule } from '../+search-page/search-page.module'; +import { MyDSpaceResultsComponent } from './my-dspace-results/my-dspace-results.component'; +import { WorkspaceitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component'; +import { ItemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component'; +import { WorkflowitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component'; +import { ClaimedMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component'; +import { PoolMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component'; +import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission/my-dspace-new-submission.component'; +import { ItemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component'; +import { WorkspaceitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component'; +import { WorkflowitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component'; +import { ClaimedMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component'; +import { PoolMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-lement.component'; +import { MyDSpaceGuard } from './my-dspace.guard'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + MyDspacePageRoutingModule, + SearchPageModule + ], + declarations: [ + MyDSpacePageComponent, + MyDSpaceResultsComponent, + ItemMyDSpaceResultListElementComponent, + WorkspaceitemMyDSpaceResultListElementComponent, + WorkflowitemMyDSpaceResultListElementComponent, + ClaimedMyDSpaceResultListElementComponent, + PoolMyDSpaceResultListElementComponent, + ItemMyDSpaceResultDetailElementComponent, + WorkspaceitemMyDSpaceResultDetailElementComponent, + WorkflowitemMyDSpaceResultDetailElementComponent, + ClaimedMyDSpaceResultDetailElementComponent, + PoolMyDSpaceResultDetailElementComponent, + MyDSpaceNewSubmissionComponent + ], + providers: [ + MyDSpaceGuard, + MyDSpaceConfigurationService + ], + entryComponents: [ + ItemMyDSpaceResultListElementComponent, + WorkspaceitemMyDSpaceResultListElementComponent, + WorkflowitemMyDSpaceResultListElementComponent, + ClaimedMyDSpaceResultListElementComponent, + PoolMyDSpaceResultListElementComponent, + ItemMyDSpaceResultDetailElementComponent, + WorkspaceitemMyDSpaceResultDetailElementComponent, + WorkflowitemMyDSpaceResultDetailElementComponent, + ClaimedMyDSpaceResultDetailElementComponent, + PoolMyDSpaceResultDetailElementComponent + ] +}) + +/** + * This module handles all components that are necessary for the mydspace page + */ +export class MyDSpacePageModule { + +} diff --git a/src/app/+my-dspace-page/my-dspace-result.model.ts b/src/app/+my-dspace-page/my-dspace-result.model.ts new file mode 100644 index 0000000000..d300ed0bc8 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-result.model.ts @@ -0,0 +1,19 @@ +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { MetadataMap } from '../core/shared/metadata.models'; +import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; + +/** + * Represents a search result object of a certain () DSpaceObject + */ +export class MyDSpaceResult implements ListableObject { + /** + * The DSpaceObject that was found + */ + indexableObject: T; + + /** + * The metadata that was used to find this item, hithighlighted + */ + hitHighlights: MetadataMap; + +} diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html new file mode 100644 index 0000000000..132a0d2204 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html @@ -0,0 +1,12 @@ +
+ + +
+ + +

{{'mydspace.results.no-results' | translate}}

diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts new file mode 100644 index 0000000000..67625706a6 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts @@ -0,0 +1,58 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { QueryParamsDirectiveStub } from '../../shared/testing/query-params-directive-stub'; +import { MyDSpaceResultsComponent } from './my-dspace-results.component'; + +describe('MyDSpaceResultsComponent', () => { + let comp: MyDSpaceResultsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule], + declarations: [ + MyDSpaceResultsComponent, + QueryParamsDirectiveStub], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpaceResultsComponent); + comp = fixture.componentInstance; // MyDSpaceResultsComponent test instance + }); + + it('should display results when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).not.toBeNull(); + }); + + it('should not display link when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('a'))).toBeNull(); + }); + + it('should display error message if error is != 400', () => { + (comp as any).searchResults = { hasFailed: true, error: { statusCode: 500 } }; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-error'))).not.toBeNull(); + }); + + it('should display a message if search result is empty', () => { + (comp as any).searchResults = { payload: { page: { length: 0 } } }; + (comp as any).searchConfig = { query: 'foobar' }; + fixture.detectChanges(); + + const linkDes = fixture.debugElement.queryAll(By.css('text-muted')); + + expect(linkDes).toBeDefined() + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts new file mode 100644 index 0000000000..3a16def9c1 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts @@ -0,0 +1,51 @@ +import { Component, Input } from '@angular/core'; + +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { MyDSpaceResult } from '../my-dspace-result.model'; +import { SearchOptions } from '../../+search-page/search-options.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { ViewMode } from '../../core/shared/view-mode.model'; +import { isEmpty } from '../../shared/empty.util'; + +/** + * Component that represents all results for mydspace page + */ +@Component({ + selector: 'ds-my-dspace-results', + templateUrl: './my-dspace-results.component.html', + animations: [ + fadeIn, + fadeInOut + ] +}) +export class MyDSpaceResultsComponent { + + /** + * The actual search result objects + */ + @Input() searchResults: RemoteData>>; + + /** + * The current configuration of the search + */ + @Input() searchConfig: SearchOptions; + + /** + * The current view mode for the search results + */ + @Input() viewMode: ViewMode; + + /** + * A boolean representing if search results entry are separated by a line + */ + hasBorder = true; + + /** + * Check if mydspace search results are loading + */ + isLoading() { + return !this.searchResults || isEmpty(this.searchResults) || this.searchResults.isLoading; + } +} diff --git a/src/app/+my-dspace-page/my-dspace.guard.ts b/src/app/+my-dspace-page/my-dspace.guard.ts new file mode 100644 index 0000000000..9cb9aff485 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace.guard.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, NavigationExtras, Router, RouterStateSnapshot } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; +import { isEmpty } from '../shared/empty.util'; +import { MYDSPACE_ROUTE } from './my-dspace-page.component'; +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; + +/** + * Prevent unauthorized activating and loading of mydspace configuration + * @class MyDSpaceGuard + */ +@Injectable() +export class MyDSpaceGuard implements CanActivate { + + /** + * @constructor + */ + constructor(private configurationService: MyDSpaceConfigurationService, private router: Router) { + } + + /** + * True when configuration is valid + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.configurationService.getAvailableConfigurationTypes().pipe( + first(), + map((configurationList) => this.validateConfigurationParam(route.queryParamMap.get('configuration'), configurationList))); + } + + /** + * Check if the given configuration is present in the list of those available + * + * @param configuration + * the configuration to validate + * @param configurationList + * the list of available configuration + * + */ + private validateConfigurationParam(configuration: string, configurationList: MyDSpaceConfigurationValueType[]): boolean { + const configurationDefault: string = configurationList[0]; + if (isEmpty(configuration) || !configurationList.includes(configuration as MyDSpaceConfigurationValueType)) { + // If configuration param is empty or is not included in available configurations redirect to a default configuration value + const navigationExtras: NavigationExtras = { + queryParams: {configuration: configurationDefault} + }; + + this.router.navigate([MYDSPACE_ROUTE], navigationExtras); + return false; + } else { + return true; + } + } +} diff --git a/src/app/+search-page/filtered-search-page.component.spec.ts b/src/app/+search-page/filtered-search-page.component.spec.ts new file mode 100644 index 0000000000..5c49767ed2 --- /dev/null +++ b/src/app/+search-page/filtered-search-page.component.spec.ts @@ -0,0 +1,37 @@ +import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { configureSearchComponentTestingModule } from './search-page.component.spec'; +import { SearchConfigurationService } from './search-service/search-configuration.service'; + +describe('FilteredSearchPageComponent', () => { + let comp: FilteredSearchPageComponent; + let fixture: ComponentFixture; + let searchConfigService: SearchConfigurationService; + + beforeEach(async(() => { + configureSearchComponentTestingModule(FilteredSearchPageComponent); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FilteredSearchPageComponent); + comp = fixture.componentInstance; + searchConfigService = (comp as any).searchConfigService; + fixture.detectChanges(); + }); + + describe('when fixedFilterQuery is defined', () => { + const fixedFilterQuery = 'fixedFilterQuery'; + + beforeEach(() => { + spyOn(searchConfigService, 'updateFixedFilter').and.callThrough(); + comp.fixedFilterQuery = fixedFilterQuery; + comp.ngOnInit(); + fixture.detectChanges(); + }); + + it('should update the paginated search options', () => { + expect(searchConfigService.updateFixedFilter).toHaveBeenCalledWith(fixedFilterQuery); + }); + }); + +}); diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts new file mode 100644 index 0000000000..d577c2c44c --- /dev/null +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -0,0 +1,58 @@ +import { HostWindowService } from '../shared/host-window.service'; +import { SearchService } from './search-service/search.service'; +import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; +import { SearchPageComponent } from './search-page.component'; +import { ChangeDetectionStrategy, Component, Inject, Input } from '@angular/core'; +import { pushInOut } from '../shared/animations/push'; +import { RouteService } from '../shared/services/route.service'; +import { SearchConfigurationService } from './search-service/search-configuration.service'; +import { Observable } from 'rxjs'; +import { PaginatedSearchOptions } from './paginated-search-options.model'; +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; + +/** + * This component renders a simple item page. + * The route parameter 'id' is used to request the item it represents. + * All fields of the item that should be displayed, are defined in its template. + */ +@Component({selector: 'ds-filtered-search-page', + styleUrls: ['./search-page.component.scss'], + templateUrl: './search-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +export class FilteredSearchPageComponent extends SearchPageComponent { + + /** + * The actual query for the fixed filter. + * If empty, the query will be determined by the route parameter called 'filter' + */ + @Input() fixedFilterQuery: string; + + constructor(protected service: SearchService, + protected sidebarService: SearchSidebarService, + protected windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + protected routeService: RouteService) { + super(service, sidebarService, windowService, searchConfigService, routeService); + } + + /** + * Get the current paginated search options after updating the fixed filter using the fixedFilterQuery input + * This is to make sure the fixed filter is included in the paginated search options, as it is not part of any + * query or route parameters + * @returns {Observable} + */ + protected getSearchOptions(): Observable { + this.searchConfigService.updateFixedFilter(this.fixedFilterQuery); + return this.searchConfigService.paginatedSearchOptions; + } + +} diff --git a/src/app/+search-page/filtered-search-page.guard.ts b/src/app/+search-page/filtered-search-page.guard.ts new file mode 100644 index 0000000000..39fbb48c67 --- /dev/null +++ b/src/app/+search-page/filtered-search-page.guard.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; + +@Injectable() +/** + * Assemble the correct i18n key for the filtered search page's title depending on the current route's filter parameter + * and title data. + * The format of the key will be "{title}{filter}.title" with: + * - title: The prefix of the key stored in route.data + * - filter: The current filter stored in route.params + */ +export class FilteredSearchPageGuard implements CanActivate { + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot): Observable | Promise | boolean { + const filter = route.params.filter; + + const newTitle = route.data.title + filter + '.title'; + + route.data = { title: newTitle }; + return true; + } +} diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts index 0683c74aed..32f3217b54 100644 --- a/src/app/+search-page/normalized-search-result.model.ts +++ b/src/app/+search-page/normalized-search-result.model.ts @@ -1,5 +1,5 @@ -import { autoserialize } from 'cerialize'; -import { Metadatum } from '../core/shared/metadatum.model'; +import { autoserialize, autoserializeAs } from 'cerialize'; +import { MetadataMap } from '../core/shared/metadata.models'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; /** @@ -10,12 +10,12 @@ export class NormalizedSearchResult implements ListableObject { * The UUID of the DSpaceObject that was found */ @autoserialize - dspaceObject: string; + indexableObject: string; /** * The metadata that was used to find this item, hithighlighted */ @autoserialize - hitHighlights: Metadatum[]; + hitHighlights: MetadataMap; } diff --git a/src/app/+search-page/paginated-search-options.model.ts b/src/app/+search-page/paginated-search-options.model.ts index 8f4d93b0df..45cd0b8f09 100644 --- a/src/app/+search-page/paginated-search-options.model.ts +++ b/src/app/+search-page/paginated-search-options.model.ts @@ -12,7 +12,7 @@ export class PaginatedSearchOptions extends SearchOptions { pagination?: PaginationComponentOptions; sort?: SortOptions; - constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], pagination?: PaginationComponentOptions, sort?: SortOptions}) { + constructor(options: {configuration?: string, scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions}) { super(options); this.pagination = options.pagination; this.sort = options.sort; diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html new file mode 100644 index 0000000000..76cdc6c8f5 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html @@ -0,0 +1,27 @@ + diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss new file mode 100644 index 0000000000..33e354f2d8 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss @@ -0,0 +1,23 @@ +@import '../../../../../styles/variables.scss'; +@import '../../../../../styles/mixins.scss'; + +.filters { + a { + color: $body-color; + &:hover, &focus { + text-decoration: none; + } + span.badge { + vertical-align: text-top; + } + } + .toggle-more-filters a { + color: $link-color; + text-decoration: underline; + cursor: pointer; + } +} +::ng-deep em { + font-weight: bold; + font-style: normal; +} diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts new file mode 100644 index 0000000000..83131e1344 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts @@ -0,0 +1,36 @@ +import { Component, OnInit } from '@angular/core'; + +import { FilterType } from '../../../search-service/filter-type.model'; +import { facetLoad, SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; +import { renderFacetFor } from '../search-filter-type-decorator'; +import { FacetValue } from '../../../search-service/facet-value.model'; + +@Component({ + selector: 'ds-search-authority-filter', + styleUrls: ['./search-authority-filter.component.scss'], + templateUrl: './search-authority-filter.component.html', + animations: [facetLoad] +}) + +/** + * Component that represents an authority facet for a specific filter configuration + */ +@renderFacetFor(FilterType.authority) +export class SearchAuthorityFilterComponent extends SearchFacetFilterComponent implements OnInit { + + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Retrieve facet value from search link + */ + protected getFacetValue(facet: FacetValue): string { + const search = facet.search; + const hashes = search.slice(search.indexOf('?') + 1).split('&'); + const params = {}; + hashes.map((hash) => { + const [key, val] = hash.split('='); + params[key] = decodeURIComponent(val) + }); + + return params[this.filterConfig.paramName]; + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html index 32d9ea6e77..cc39b80db8 100644 --- a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html @@ -1,24 +1,9 @@
- - - {{value}} - +
diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss index 030184640e..a28db359b5 100644 --- a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss @@ -2,15 +2,6 @@ @import '../../../../../styles/mixins.scss'; .filters { - a { - color: $body-color; - &:hover, &focus { - text-decoration: none; - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html new file mode 100644 index 0000000000..7ab7ffd0ca --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -0,0 +1,9 @@ + + + {{filterValue.value}} + + {{filterValue.count}} + + \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss new file mode 100644 index 0000000000..6452f2469b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss @@ -0,0 +1,11 @@ +@import '../../../../../../styles/variables.scss'; + +a { + color: $body-color; + &:hover, &focus { + text-decoration: none; + } + span.badge { + vertical-align: text-top; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts new file mode 100644 index 0000000000..245c0e3ddb --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts @@ -0,0 +1,162 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SearchFacetOptionComponent } from './search-facet-option.component'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { FormsModule } from '@angular/forms'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../../../shared/testing/router-stub'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { By } from '@angular/platform-browser'; + +describe('SearchFacetOptionComponent', () => { + let comp: SearchFacetOptionComponent; + let fixture: ComponentFixture; + const filterName1 = 'testname'; + const filterName2 = 'testAuthorityname'; + const value1 = 'testvalue1'; + const value2 = 'test2'; + const operator = 'authority'; + + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + + const mockAuthorityFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName2, + type: FilterType.authority, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2 + }); + + const value: FacetValue = { + label: value2, + value: value2, + count: 20, + search: `` + }; + + const selectedValue: FacetValue = { + label: value1, + value: value1, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1},${operator}` + }; + + const authorityValue: FacetValue = { + label: value2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + }; + + const searchLink = '/search'; + const selectedValues = [selectedValue]; + const selectedValues$ = observableOf(selectedValues); + let filterService; + let searchService; + let router; + const page = observableOf(0); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchFacetOptionComponent], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: Router, useValue: new RouterStub() }, + { + provide: SearchConfigurationService, useValue: { + searchOptions: observableOf({}) + } + }, + { + provide: SearchFilterService, useValue: { + getSelectedValuesForFilter: () => selectedValues, + isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true), + getPage: (paramName: string) => page, + /* tslint:disable:no-empty */ + incrementPage: (filterName: string) => { + }, + resetPage: (filterName: string) => { + } + /* tslint:enable:no-empty */ + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchFacetOptionComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchFacetOptionComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + filterService = (comp as any).filterService; + searchService = (comp as any).searchService; + router = (comp as any).router; + comp.filterValue = value; + comp.selectedValues$ = selectedValues$; + comp.filterConfig = mockFilterConfig; + fixture.detectChanges(); + }); + + describe('when the updateAddParams method is called with a value', () => { + it('should update the addQueryParams with the new parameter values', () => { + comp.addQueryParams = {}; + (comp as any).updateAddParams(selectedValues); + expect(comp.addQueryParams).toEqual({ + [mockFilterConfig.paramName]: [value1, value.value], + page: 1 + }); + }); + }); + + describe('when filter type is authority and the updateAddParams method is called with a value', () => { + it('should update the addQueryParams with the new parameter values', () => { + comp.filterValue = authorityValue; + comp.filterConfig = mockAuthorityFilterConfig; + fixture.detectChanges(); + + comp.addQueryParams = {}; + (comp as any).updateAddParams(selectedValues); + expect(comp.addQueryParams).toEqual({ + [mockAuthorityFilterConfig.paramName]: [value1, `${value2},${operator}`], + page: 1 + }); + }); + }); + + describe('when isVisible emits true', () => { + it('the facet option should be visible', () => { + comp.isVisible = observableOf(true); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).not.toBeNull(); + }); + }); + + describe('when isVisible emits false', () => { + it('the facet option should not be visible', () => { + comp.isVisible = observableOf(false); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).toBeNull(); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts new file mode 100644 index 0000000000..1fccee3736 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts @@ -0,0 +1,132 @@ +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { hasValue } from '../../../../../shared/empty.util'; +import { FilterType } from '../../../../search-service/filter-type.model'; + +@Component({ + selector: 'ds-search-facet-option', + styleUrls: ['./search-facet-option.component.scss'], + templateUrl: './search-facet-option.component.html', +}) + +/** + * Represents a single option in a filter facet + */ +export class SearchFacetOptionComponent implements OnInit, OnDestroy { + /** + * A single value for this component + */ + @Input() filterValue: FacetValue; + + /** + * The filter configuration for this facet option + */ + @Input() filterConfig: SearchFilterConfig; + + /** + * Emits the active values for this filter + */ + @Input() selectedValues$: Observable; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + + /** + * Emits true when this option should be visible and false when it should be invisible + */ + isVisible: Observable; + + /** + * UI parameters when this filter is added + */ + addQueryParams; + + /** + * Subscription to unsubscribe from on destroy + */ + sub: Subscription; + + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected searchConfigService: SearchConfigurationService, + protected router: Router + ) { + } + + /** + * Initializes all observable instance variables and starts listening to them + */ + ngOnInit(): void { + this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); + this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions) + .subscribe(([selectedValues, searchOptions]) => { + this.updateAddParams(selectedValues) + }); + } + + /** + * Checks if a value for this filter is currently active + */ + private isChecked(): Observable { + return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.getFacetValue()); + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.searchService.getSearchLink(); + } + + /** + * Calculates the parameters that should change if a given value for this filter would be added to the active filters + * @param {string[]} selectedValues The values that are currently selected for this filter + */ + private updateAddParams(selectedValues: FacetValue[]): void { + this.addQueryParams = { + [this.filterConfig.paramName]: [...selectedValues.map((facetValue: FacetValue) => facetValue.label), this.getFacetValue()], + page: 1 + }; + } + + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Retrieve facet value related to facet type + */ + private getFacetValue(): string { + if (this.filterConfig.type === FilterType.authority) { + const search = this.filterValue.search; + const hashes = search.slice(search.indexOf('?') + 1).split('&'); + const params = {}; + hashes.map((hash) => { + const [key, val] = hash.split('='); + params[key] = decodeURIComponent(val) + }); + + return params[this.filterConfig.paramName]; + } else { + return this.filterValue.value; + } + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html new file mode 100644 index 0000000000..8e8ad9b4e3 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html @@ -0,0 +1,8 @@ + + {{filterValue.label}} + + {{filterValue.count}} + + diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss new file mode 100644 index 0000000000..7b5448b980 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss @@ -0,0 +1,13 @@ +@import '../../../../../../styles/variables.scss'; + +a { + color: $link-color; + &:hover { + text-decoration: underline; + color: $link-hover-color; + + } + span.badge { + vertical-align: text-top; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts new file mode 100644 index 0000000000..d3264214ed --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts @@ -0,0 +1,127 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { FormsModule } from '@angular/forms'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../../../shared/testing/router-stub'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { By } from '@angular/platform-browser'; +import { SearchFacetRangeOptionComponent } from './search-facet-range-option.component'; +import { + RANGE_FILTER_MAX_SUFFIX, + RANGE_FILTER_MIN_SUFFIX +} from '../../search-range-filter/search-range-filter.component'; + +describe('SearchFacetRangeOptionComponent', () => { + let comp: SearchFacetRangeOptionComponent; + let fixture: ComponentFixture; + const filterName1 = 'test name'; + const value2 = '20 - 30'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + const value: FacetValue = { + label: value2, + value: value2, + count: 20, + search: '' + }; + + const searchLink = '/search'; + let filterService; + let searchService; + let router; + const page = observableOf(0); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchFacetRangeOptionComponent], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: Router, useValue: new RouterStub() }, + { + provide: SearchConfigurationService, useValue: { + searchOptions: observableOf({}) + } + }, + { + provide: SearchFilterService, useValue: { + isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true), + getPage: (paramName: string) => page, + /* tslint:disable:no-empty */ + incrementPage: (filterName: string) => { + }, + resetPage: (filterName: string) => { + } + /* tslint:enable:no-empty */ + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchFacetRangeOptionComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchFacetRangeOptionComponent); + comp = fixture.componentInstance; // SearchFacetRangeOptionComponent test instance + filterService = (comp as any).filterService; + searchService = (comp as any).searchService; + router = (comp as any).router; + comp.filterValue = value; + comp.filterConfig = mockFilterConfig; + fixture.detectChanges(); + }); + + describe('when the updateChangeParams method is called wih a value', () => { + it('should update the changeQueryParams with the new parameter values', () => { + comp.changeQueryParams = {}; + comp.filterValue = { + label: '50-60', + value: '50-60', + count: 20, + search: '' + }; + (comp as any).updateChangeParams(); + expect(comp.changeQueryParams).toEqual({ + [mockFilterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: ['50'], + [mockFilterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: ['60'], + page: 1 + }); + }); + }); + + describe('when isVisible emits true', () => { + it('the facet option should be visible', () => { + comp.isVisible = observableOf(true); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).not.toBeNull(); + }); + }); + + describe('when isVisible emits false', () => { + it('the facet option should not be visible', () => { + comp.isVisible = observableOf(false); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).toBeNull(); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts new file mode 100644 index 0000000000..54d5d535df --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts @@ -0,0 +1,114 @@ +import { Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { + RANGE_FILTER_MAX_SUFFIX, + RANGE_FILTER_MIN_SUFFIX +} from '../../search-range-filter/search-range-filter.component'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { hasValue } from '../../../../../shared/empty.util'; + +const rangeDelimiter = '-'; + +@Component({ + selector: 'ds-search-facet-range-option', + styleUrls: ['./search-facet-range-option.component.scss'], + templateUrl: './search-facet-range-option.component.html', +}) + +/** + * Represents a single option in a range filter facet + */ +export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { + /** + * A single value for this component + */ + @Input() filterValue: FacetValue; + + /** + * The filter configuration for this facet option + */ + @Input() filterConfig: SearchFilterConfig; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + + /** + * Emits true when this option should be visible and false when it should be invisible + */ + isVisible: Observable; + + /** + * UI parameters when this filter is changed + */ + changeQueryParams; + + /** + * Subscription to unsubscribe from on destroy + */ + sub: Subscription; + + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected searchConfigService: SearchConfigurationService, + protected router: Router + ) { + } + + /** + * Initializes all observable instance variables and starts listening to them + */ + ngOnInit(): void { + this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); + this.sub = this.searchConfigService.searchOptions.subscribe(() => { + this.updateChangeParams() + }); + } + + /** + * Checks if a value for this filter is currently active + */ + private isChecked(): Observable { + return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value); + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.searchService.getSearchLink(); + } + + /** + * Calculates the parameters that should change if a given values for this range filter would be changed + */ + private updateChangeParams(): void { + const parts = this.filterValue.value.split(rangeDelimiter); + const min = parts.length > 1 ? parts[0].trim() : this.filterValue.value; + const max = parts.length > 1 ? parts[1].trim() : this.filterValue.value; + this.changeQueryParams = { + [this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: [min], + [this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: [max], + page: 1 + }; + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html new file mode 100644 index 0000000000..5657bd224e --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html @@ -0,0 +1,6 @@ + + + {{selectedValue.label}} + diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss new file mode 100644 index 0000000000..6452f2469b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss @@ -0,0 +1,11 @@ +@import '../../../../../../styles/variables.scss'; + +a { + color: $body-color; + &:hover, &focus { + text-decoration: none; + } + span.badge { + vertical-align: text-top; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts new file mode 100644 index 0000000000..01defb9893 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts @@ -0,0 +1,161 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; +import { FormsModule } from '@angular/forms'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../../../shared/testing/router-stub'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { SearchFacetSelectedOptionComponent } from './search-facet-selected-option.component'; +import { FacetValue } from '../../../../search-service/facet-value.model'; + +describe('SearchFacetSelectedOptionComponent', () => { + let comp: SearchFacetSelectedOptionComponent; + let fixture: ComponentFixture; + const filterName1 = 'test name'; + const filterName2 = 'testAuthorityname'; + const label1 = 'test value 1'; + const value1 = 'testvalue1'; + const label2 = 'test 2'; + const value2 = 'test2'; + const operator = 'authority'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + const mockAuthorityFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName2, + type: FilterType.authority, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2 + }); + + const searchLink = '/search'; + const selectedValue: FacetValue = { + label: value1, + value: value1, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1}` + }; + const selectedValue2: FacetValue = { + label: value2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value2}` + }; + const selectedAuthorityValue: FacetValue = { + label: label1, + value: value1, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value1},${operator}` + }; + const selectedAuthorityValue2: FacetValue = { + label: label2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + }; + const selectedValues = [selectedValue, selectedValue2]; + const selectedAuthorityValues = [selectedAuthorityValue, selectedAuthorityValue2]; + const facetValue = { + label: value2, + value: value2, + count: 1, + search: '' + }; + const authorityValue: FacetValue = { + label: label2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + }; + const selectedValues$ = observableOf(selectedValues); + const selectedAuthorityValues$ = observableOf(selectedAuthorityValues); + let filterService; + let searchService; + let router; + const page = observableOf(0); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchFacetSelectedOptionComponent], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: Router, useValue: new RouterStub() }, + { + provide: SearchConfigurationService, useValue: { + searchOptions: observableOf({}) + } + }, + { + provide: SearchFilterService, useValue: { + getSelectedValuesForFilter: () => selectedValues, + isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true), + getPage: (paramName: string) => page, + /* tslint:disable:no-empty */ + incrementPage: (filterName: string) => { + }, + resetPage: (filterName: string) => { + } + /* tslint:enable:no-empty */ + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchFacetSelectedOptionComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchFacetSelectedOptionComponent); + comp = fixture.componentInstance; // SearchFacetSelectedOptionComponent test instance + filterService = (comp as any).filterService; + searchService = (comp as any).searchService; + router = (comp as any).router; + comp.selectedValue = facetValue; + comp.selectedValues$ = selectedValues$; + comp.filterConfig = mockFilterConfig; + fixture.detectChanges(); + }); + + describe('when the updateRemoveParams method is called wih a value', () => { + it('should update the removeQueryParams with the new parameter values', () => { + comp.removeQueryParams = {}; + (comp as any).updateRemoveParams(selectedValues); + expect(comp.removeQueryParams).toEqual({ + [mockFilterConfig.paramName]: [value1], + page: 1 + }); + }); + }); + + describe('when filter type is authority and the updateRemoveParams method is called with a value', () => { + it('should update the removeQueryParams with the new parameter values', () => { + spyOn(filterService, 'getSelectedValuesForFilter').and.returnValue(selectedAuthorityValues); + comp.selectedValue = authorityValue; + comp.selectedValues$ = selectedAuthorityValues$; + comp.filterConfig = mockAuthorityFilterConfig; + comp.removeQueryParams = {}; + fixture.detectChanges(); + (comp as any).updateRemoveParams(selectedAuthorityValues); + expect(comp.removeQueryParams).toEqual({ + [mockAuthorityFilterConfig.paramName]: [`${value1},${operator}`], + page: 1 + }); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts new file mode 100644 index 0000000000..78dde92c2b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts @@ -0,0 +1,120 @@ +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { hasValue } from '../../../../../shared/empty.util'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; + +@Component({ + selector: 'ds-search-facet-selected-option', + styleUrls: ['./search-facet-selected-option.component.scss'], + templateUrl: './search-facet-selected-option.component.html', +}) + +/** + * Represents a single selected option in a filter facet + */ +export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { + /** + * The value for this component + */ + @Input() selectedValue: FacetValue; + + /** + * The filter configuration for this facet option + */ + @Input() filterConfig: SearchFilterConfig; + + /** + * Emits the active values for this filter + */ + @Input() selectedValues$: Observable; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + + /** + * UI parameters when this filter is removed + */ + removeQueryParams; + + /** + * Subscription to unsubscribe from on destroy + */ + sub: Subscription; + + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected searchConfigService: SearchConfigurationService, + protected router: Router + ) { + } + + /** + * Initializes all observable instance variables and starts listening to them + */ + ngOnInit(): void { + this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions) + .subscribe(([selectedValues, searchOptions]) => { + this.updateRemoveParams(selectedValues) + }); + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.searchService.getSearchLink(); + } + + /** + * Calculates the parameters that should change if a given value for this filter would be removed from the active filters + * @param {string[]} selectedValues The values that are currently selected for this filter + */ + private updateRemoveParams(selectedValues: FacetValue[]): void { + this.removeQueryParams = { + [this.filterConfig.paramName]: selectedValues + .filter((facetValue: FacetValue) => facetValue.label !== this.selectedValue.label) + .map((facetValue: FacetValue) => this.getFacetValue(facetValue)), + page: 1 + }; + } + + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Retrieve facet value related to facet type + */ + private getFacetValue(facetValue: FacetValue): string { + if (this.filterConfig.type === FilterType.authority) { + const search = facetValue.search; + const hashes = search.slice(search.indexOf('?') + 1).split('&'); + const params = {}; + hashes.map((hash) => { + const [key, val] = hash.split('='); + params[key] = decodeURIComponent(val) + }); + + return params[this.filterConfig.paramName]; + } else { + return facetValue.value; + } + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html index b7e03af473..4a325d9b3c 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts index bc088777fa..6720b30681 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts @@ -2,7 +2,9 @@ import { Component, Injector, Input, OnInit } from '@angular/core'; import { renderFilterType } from '../search-filter-type-decorator'; import { FilterType } from '../../../search-service/filter-type.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; -import { FILTER_CONFIG } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH } from '../search-filter.service'; +import { GenericConstructor } from '../../../../core/shared/generic-constructor'; +import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; @Component({ selector: 'ds-search-facet-filter-wrapper', @@ -18,6 +20,15 @@ export class SearchFacetFilterWrapperComponent implements OnInit { */ @Input() filterConfig: SearchFilterConfig; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + + /** + * The constructor of the search facet filter that should be rendered, based on the filter config's type + */ + searchFilter: GenericConstructor; /** * Injector to inject a child component with the @Input parameters */ @@ -30,9 +41,11 @@ export class SearchFacetFilterWrapperComponent implements OnInit { * Initialize and add the filter config to the injector */ ngOnInit(): void { + this.searchFilter = this.getSearchFilter(); this.objectInjector = Injector.create({ providers: [ - { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] } + { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }, + { provide: IN_PLACE_SEARCH, useFactory: () => (this.inPlaceSearch), deps: [] } ], parent: this.injector }); @@ -41,7 +54,7 @@ export class SearchFacetFilterWrapperComponent implements OnInit { /** * Find the correct component based on the filter config's type */ - getSearchFilter() { + private getSearchFilter() { const type: FilterType = this.filterConfig.type; return renderFilterType(type); } diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts index 498c41dd6c..5d8b51de96 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { FilterType } from '../../../search-service/filter-type.model'; import { FacetValue } from '../../../search-service/facet-value.model'; @@ -17,7 +17,9 @@ import { Router } from '@angular/router'; import { PageInfo } from '../../../../core/shared/page-info.model'; import { SearchFacetFilterComponent } from './search-facet-filter.component'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; -import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { tap } from 'rxjs/operators'; describe('SearchFacetFilterComponent', () => { let comp: SearchFacetFilterComponent; @@ -35,14 +37,17 @@ describe('SearchFacetFilterComponent', () => { }); const values: FacetValue[] = [ { + label: value1, value: value1, count: 52, search: '' }, { + label: value2, value: value2, count: 20, search: '' }, { + label: value3, value: value3, count: 5, search: '' @@ -65,8 +70,9 @@ describe('SearchFacetFilterComponent', () => { { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, { provide: Router, useValue: new RouterStub() }, { provide: FILTER_CONFIG, useValue: new SearchFilterConfig() }, - { provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} }, - { provide: SearchConfigurationService, useValue: {searchOptions: observableOf({})} }, + { provide: RemoteDataBuildService, useValue: { aggregate: () => observableOf({}) } }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + { provide: IN_PLACE_SEARCH, useValue: false }, { provide: SearchFilterService, useValue: { getSelectedValuesForFilter: () => observableOf(selectedValues), @@ -120,20 +126,6 @@ describe('SearchFacetFilterComponent', () => { }); }); - describe('when the getAddParams method is called wih a value', () => { - it('should return the selectedValue list with the new parameter value', () => { - const result = comp.getAddParams(value3); - result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value1, value2, value3])); - }); - }); - - describe('when the getRemoveParams method is called wih a value', () => { - it('should return the selectedValue list with the parameter value left out', () => { - const result = comp.getRemoveParams(value1); - result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2])); - }); - }); - describe('when the showMore method is called', () => { beforeEach(() => { spyOn(filterService, 'incrementPage'); @@ -182,13 +174,20 @@ describe('SearchFacetFilterComponent', () => { const searchUrl = '/search/path'; const testValue = 'test'; const data = testValue; + beforeEach(() => { + comp.selectedValues$ = observableOf(selectedValues.map((value) => + Object.assign(new FacetValue(), { + label: value, + value: value + }))); + fixture.detectChanges(); spyOn(comp, 'getSearchLink').and.returnValue(searchUrl); comp.onSubmit(data); }); it('should call navigate on the router with the right searchlink and parameters', () => { - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { + expect(router.navigate).toHaveBeenCalledWith(searchUrl.split('/'), { queryParams: { [mockFilterConfig.paramName]: [...selectedValues, testValue] }, queryParamsHandling: 'merge' }); @@ -202,9 +201,9 @@ describe('SearchFacetFilterComponent', () => { }); it('should call showFirstPageOnly and empty the filter', () => { - expect(comp.animationState).toEqual('loading'); - expect((comp as any).collapseNextUpdate).toBeTruthy(); - expect(comp.filter).toEqual(''); + expect(comp.animationState).toEqual('loading'); + expect((comp as any).collapseNextUpdate).toBeTruthy(); + expect(comp.filter).toEqual(''); }); }); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index fd5a75e7d1..772240eb0b 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -6,7 +6,7 @@ import { Subject, Subscription } from 'rxjs'; -import { switchMap, distinctUntilChanged, map, take } from 'rxjs/operators'; +import { switchMap, distinctUntilChanged, map, take, flatMap, tap } from 'rxjs/operators'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; @@ -18,9 +18,12 @@ import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe'; import { FacetValue } from '../../../search-service/facet-value.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { SearchService } from '../../../search-service/search.service'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; +import { SearchOptions } from '../../../search-options.model'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-facet-filter', @@ -54,29 +57,35 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * List of subscriptions to unsubscribe from */ - private subs: Subscription[] = []; + protected subs: Subscription[] = []; /** * Emits the result values for this filter found by the current filter query */ - filterSearchResults: Observable = observableOf([]); + filterSearchResults: Observable = observableOf([]); /** * Emits the active values for this filter */ - selectedValues: Observable; - private collapseNextUpdate = true; + selectedValues$: Observable; + protected collapseNextUpdate = true; /** * State of the requested facets used to time the animation */ animationState = 'loading'; + /** + * Emits all current search options available in the search URL + */ + searchOptions$: Observable; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, - protected searchConfigService: SearchConfigurationService, protected rdbs: RemoteDataBuildService, protected router: Router, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + @Inject(IN_PLACE_SEARCH) public inPlaceSearch: boolean, @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig) { } @@ -86,10 +95,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { ngOnInit(): void { this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined)); this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged()); - this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig); - const searchOptions = this.searchConfigService.searchOptions; - this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList())); - const facetValues = observableCombineLatest(searchOptions, this.currentPage).pipe( + + this.searchOptions$ = this.searchConfigService.searchOptions; + this.subs.push(this.searchOptions$.subscribe(() => this.updateFilterValueList())); + const facetValues$ = observableCombineLatest(this.searchOptions$, this.currentPage).pipe( map(([options, page]) => { return { options, page } }), @@ -107,8 +116,9 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { ) }) ); + let filterValues = []; - this.subs.push(facetValues.subscribe((facetOutcome) => { + this.subs.push(facetValues$.subscribe((facetOutcome) => { const newValues$ = facetOutcome.values; if (this.collapseNextUpdate) { @@ -122,9 +132,24 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { filterValues = [...filterValues, newValues$]; - this.subs.push(this.rdbs.aggregate(filterValues).subscribe((rd: RemoteData>>) => { + this.subs.push(this.rdbs.aggregate(filterValues).pipe( + tap((rd: RemoteData>>) => { + this.selectedValues$ = this.filterService.getSelectedValuesForFilter(this.filterConfig).pipe( + map((selectedValues) => { + return selectedValues.map((value: string) => { + const fValue = [].concat(...rd.payload.map((page) => page.page)).find((facetValue: FacetValue) => facetValue.value === value); + if (hasValue(fValue)) { + return fValue; + } + return Object.assign(new FacetValue(), { label: value, value: value }); + }); + }) + ); + }) + ).subscribe((rd: RemoteData>>) => { this.animationState = 'ready'; this.filterValues$.next(rd); + })); this.subs.push(newValues$.pipe(take(1)).subscribe((rd) => { this.isLastPage$.next(hasNoValue(rd.payload.next)) @@ -150,12 +175,25 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } + /** + * @returns {string[]} The base path to the search page, or the current page when inPlaceSearch is true, split in separate pieces + */ + public getSearchLinkParts(): string[] { + if (this.inPlaceSearch) { + return []; + } + return this.getSearchLink().split('/'); + } + /** * Show the next page as well */ @@ -189,11 +227,16 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * @param data The string from the input field */ onSubmit(data: any) { - this.selectedValues.pipe(take(1)).subscribe((selectedValues) => { + this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { if (isNotEmpty(data)) { - this.router.navigate([this.getSearchLink()], { + this.router.navigate(this.getSearchLinkParts(), { queryParams: - { [this.filterConfig.paramName]: [...selectedValues, data] }, + { + [this.filterConfig.paramName]: [ + ...selectedValues.map((facet) => this.getFacetValue(facet)), + data + ] + }, queryParamsHandling: 'merge' }); this.filter = ''; @@ -203,6 +246,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { ) } + /** + * On click, set the input's value to the clicked data + * @param data The value of the option that was clicked + */ onClick(data: any) { this.filter = data; } @@ -214,34 +261,6 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { return hasValue(o); } - /** - * Calculates the parameters that should change if a given value for this filter would be removed from the active filters - * @param {string} value The value that is removed for this filter - * @returns {Observable} The changed filter parameters - */ - getRemoveParams(value: string): Observable { - return this.selectedValues.pipe(map((selectedValues) => { - return { - [this.filterConfig.paramName]: selectedValues.filter((v) => v !== value), - page: 1 - }; - })); - } - - /** - * Calculates the parameters that should change if a given value for this filter would be added to the active filters - * @param {string} value The value that is added for this filter - * @returns {Observable} The changed filter parameters - */ - getAddParams(value: string): Observable { - return this.selectedValues.pipe(map((selectedValues) => { - return { - [this.filterConfig.paramName]: [...selectedValues, value], - page: 1 - }; - })); - } - /** * Unsubscribe from all subscriptions */ @@ -258,7 +277,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ findSuggestions(data): void { if (isNotEmpty(data)) { - this.searchConfigService.searchOptions.pipe(take(1)).subscribe( + this.searchOptions$.pipe(take(1)).subscribe( (options) => { this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase()) .pipe( @@ -266,7 +285,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { map( (rd: RemoteData>) => { return rd.payload.page.map((facet) => { - return { displayValue: this.getDisplayValue(facet, data), value: facet.value } + return { + displayValue: this.getDisplayValue(facet, data), + value: this.getFacetValue(facet) + } }) } )) @@ -277,6 +299,13 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { } } + /** + * Retrieve facet value + */ + protected getFacetValue(facet: FacetValue): string { + return facet.value; + } + /** * Transforms the facet value string, so if the query matches part of the value, it's emphasized in the value * @param {FacetValue} facet The value of the facet as returned by the server @@ -286,6 +315,13 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { getDisplayValue(facet: FacetValue, query: string): string { return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')'; } + + /** + * Prevent unnecessary rerendering + */ + trackUpdate(index, value: FacetValue) { + return value ? value.search : undefined; + } } export const facetLoad = trigger('facetLoad', [ diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts index 2e556b32d6..f7f80eefff 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts @@ -1,6 +1,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../../shared/ngrx/type'; +import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; /** * For each action type in an action group, make a simple @@ -12,9 +13,8 @@ import { type } from '../../../shared/ngrx/type'; */ export const SearchFilterActionTypes = { COLLAPSE: type('dspace/search-filter/COLLAPSE'), - INITIAL_COLLAPSE: type('dspace/search-filter/INITIAL_COLLAPSE'), + INITIALIZE: type('dspace/search-filter/INITIALIZE'), EXPAND: type('dspace/search-filter/EXPAND'), - INITIAL_EXPAND: type('dspace/search-filter/INITIAL_EXPAND'), TOGGLE: type('dspace/search-filter/TOGGLE'), DECREMENT_PAGE: type('dspace/search-filter/DECREMENT_PAGE'), INCREMENT_PAGE: type('dspace/search-filter/INCREMENT_PAGE'), @@ -64,17 +64,15 @@ export class SearchFilterToggleAction extends SearchFilterAction { } /** - * Used to set the initial state of a filter to collapsed + * Used to set the initial state of a filter */ -export class SearchFilterInitialCollapseAction extends SearchFilterAction { - type = SearchFilterActionTypes.INITIAL_COLLAPSE; -} - -/** - * Used to set the initial state of a filter to expanded - */ -export class SearchFilterInitialExpandAction extends SearchFilterAction { - type = SearchFilterActionTypes.INITIAL_EXPAND; +export class SearchFilterInitializeAction extends SearchFilterAction { + type = SearchFilterActionTypes.INITIALIZE; + initiallyExpanded; + constructor(filter: SearchFilterConfig) { + super(filter.name); + this.initiallyExpanded = filter.isOpenByDefault; + } } /** diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-filter.component.html index 1013bf7e28..a1758d7339 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.html @@ -1,7 +1,7 @@ -
+
{{'search.filters.filter.' + filter.name + '.head'| translate}}
-
- + [ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'">
+
+
-
\ No newline at end of file +
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss index 4e45f49468..1db5e9a1b2 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss @@ -1,7 +1,7 @@ @import '../../../../styles/variables.scss'; @import '../../../../styles/mixins.scss'; -:host { +:host .facet-filter { border: 1px solid map-get($theme-colors, light); cursor: pointer; .search-filter-wrapper.closed { diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts index caa5a6febc..23c4ab3b53 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts @@ -10,6 +10,9 @@ import { SearchService } from '../../search-service/search.service'; import { SearchFilterComponent } from './search-filter.component'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { FilterType } from '../../search-service/filter-type.model'; +import { SearchConfigurationService } from '../../search-service/search-configuration.service'; +import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service-stub'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -33,9 +36,7 @@ describe('SearchFilterComponent', () => { }, expand: (filter) => { }, - initialCollapse: (filter) => { - }, - initialExpand: (filter) => { + initializeFilter: (filter) => { }, getSelectedValuesForFilter: (filter) => { return observableOf([filterName1, filterName2, filterName3]) @@ -65,6 +66,7 @@ describe('SearchFilterComponent', () => { provide: SearchFilterService, useValue: mockFilterService }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchFilterComponent, { @@ -91,32 +93,21 @@ describe('SearchFilterComponent', () => { }); }); - describe('when the initialCollapse method is triggered', () => { + describe('when the initializeFilter method is triggered', () => { beforeEach(() => { - spyOn(filterService, 'initialCollapse'); - comp.initialCollapse(); + spyOn(filterService, 'initializeFilter'); + comp.initializeFilter(); }); it('should call initialCollapse with the correct filter configuration name', () => { - expect(filterService.initialCollapse).toHaveBeenCalledWith(mockFilterConfig.name) - }); - }); - - describe('when the initialExpand method is triggered', () => { - beforeEach(() => { - spyOn(filterService, 'initialExpand'); - comp.initialExpand(); - }); - - it('should call initialCollapse with the correct filter configuration name', () => { - expect(filterService.initialExpand).toHaveBeenCalledWith(mockFilterConfig.name) + expect(filterService.initializeFilter).toHaveBeenCalledWith(mockFilterConfig) }); }); describe('when getSelectedValues is called', () => { let valuesObservable: Observable; beforeEach(() => { - valuesObservable = comp.getSelectedValues(); + valuesObservable = (comp as any).getSelectedValues(); }); it('should return an observable containing the existing filters', () => { @@ -141,7 +132,7 @@ describe('SearchFilterComponent', () => { let isActive: Observable; beforeEach(() => { filterService.isCollapsed = () => observableOf(true); - isActive = comp.isCollapsed(); + isActive = (comp as any).isCollapsed(); }); it('should return an observable containing true', () => { @@ -156,7 +147,7 @@ describe('SearchFilterComponent', () => { let isActive: Observable; beforeEach(() => { filterService.isCollapsed = () => observableOf(false); - isActive = comp.isCollapsed(); + isActive = (comp as any).isCollapsed(); }); it('should return an observable containing false', () => { diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts index 289b5da143..bfe9f3be63 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts @@ -1,10 +1,15 @@ -import { take } from 'rxjs/operators'; -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { filter, first, map, startWith, switchMap, take } from 'rxjs/operators'; + import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterService } from './search-filter.service'; -import { Observable } from 'rxjs'; import { slide } from '../../../shared/animations/slide'; import { isNotEmpty } from '../../../shared/empty.util'; +import { SearchService } from '../../search-service/search.service'; +import { SearchConfigurationService } from '../../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-filter', @@ -22,12 +27,35 @@ export class SearchFilterComponent implements OnInit { */ @Input() filter: SearchFilterConfig; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * True when the filter is 100% collapsed in the UI */ - collapsed; + closed = true; - constructor(private filterService: SearchFilterService) { + /** + * Emits true when the filter is currently collapsed in the store + */ + collapsed$: Observable; + + /** + * Emits all currently selected values for this filter + */ + selectedValues$: Observable; + + /** + * Emits true when the current filter is supposed to be shown + */ + active$: Observable; + + constructor( + private filterService: SearchFilterService, + private searchService: SearchService, + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { } /** @@ -36,11 +64,13 @@ export class SearchFilterComponent implements OnInit { * Else, the filter should initially be collapsed */ ngOnInit() { - this.getSelectedValues().pipe(take(1)).subscribe((isActive) => { - if (this.filter.isOpenByDefault || isNotEmpty(isActive)) { - this.initialExpand(); - } else { - this.initialCollapse(); + this.selectedValues$ = this.getSelectedValues(); + this.active$ = this.isActive(); + this.collapsed$ = this.isCollapsed(); + this.initializeFilter(); + this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { + if (isNotEmpty(selectedValues)) { + this.filterService.expand(this.filter.name); } }); } @@ -56,30 +86,21 @@ export class SearchFilterComponent implements OnInit { * Checks if the filter is currently collapsed * @returns {Observable} Emits true when the current state of the filter is collapsed, false when it's expanded */ - isCollapsed(): Observable { + private isCollapsed(): Observable { return this.filterService.isCollapsed(this.filter.name); } /** - * Changes the initial state to collapsed + * Sets the initial state of the filter */ - initialCollapse() { - this.filterService.initialCollapse(this.filter.name); - this.collapsed = true; - } - - /** - * Changes the initial state to expanded - */ - initialExpand() { - this.filterService.initialExpand(this.filter.name); - this.collapsed = false; + initializeFilter() { + this.filterService.initializeFilter(this.filter); } /** * @returns {Observable} Emits a list of all values that are currently active for this filter */ - getSelectedValues(): Observable { + private getSelectedValues(): Observable { return this.filterService.getSelectedValuesForFilter(this.filter); } @@ -89,7 +110,7 @@ export class SearchFilterComponent implements OnInit { */ finishSlide(event: any): void { if (event.fromState === 'collapsed') { - this.collapsed = false; + this.closed = false; } } @@ -99,7 +120,31 @@ export class SearchFilterComponent implements OnInit { */ startSlide(event: any): void { if (event.toState === 'collapsed') { - this.collapsed = true; + this.closed = true; } } + + /** + * Check if a given filter is supposed to be shown or not + * @returns {Observable} Emits true whenever a given filter config should be shown + */ + private isActive(): Observable { + return this.selectedValues$.pipe( + switchMap((isActive) => { + if (isNotEmpty(isActive)) { + return observableOf(true); + } else { + return this.searchConfigService.searchOptions.pipe( + switchMap((options) => { + return this.searchService.getFacetValuesFor(this.filter, 1, options).pipe( + filter((RD) => !RD.isLoading), + map((valuesRD) => { + return valuesRD.payload.totalElements > 0 + }),) + } + )) + } + }), + startWith(true)); + } } diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts index 8fbfbf2e65..2f3268fba5 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts @@ -1,10 +1,8 @@ import * as deepFreeze from 'deep-freeze'; import { SearchFilterCollapseAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, SearchFilterToggleAction, - SearchFilterDecrementPageAction, SearchFilterResetPageAction + SearchFilterDecrementPageAction, SearchFilterResetPageAction, SearchFilterInitializeAction } from './search-filter.actions'; import { filterReducer } from './search-filter.reducer'; @@ -98,35 +96,39 @@ describe('filterReducer', () => { filterReducer(state, action); }); - it('should set filterCollapsed to true in response to the INITIAL_COLLAPSE action when no state has been set for this filter', () => { + it('should set filterCollapsed to true in response to the INITIALIZE action with isOpenByDefault to false when no state has been set for this filter', () => { const state = {}; state[filterName2] = { filterCollapsed: false, page: 1 }; - const action = new SearchFilterInitialCollapseAction(filterName1); + const filterConfig = {isOpenByDefault: false, name: filterName1} as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState[filterName1].filterCollapsed).toEqual(true); }); - it('should set filterCollapsed to true in response to the INITIAL_EXPAND action when no state has been set for this filter', () => { + it('should set filterCollapsed to false in response to the INITIALIZE action with isOpenByDefault to true when no state has been set for this filter', () => { const state = {}; state[filterName2] = { filterCollapsed: true, page: 1 }; - const action = new SearchFilterInitialExpandAction(filterName1); + const filterConfig = {isOpenByDefault: true, name: filterName1} as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState[filterName1].filterCollapsed).toEqual(false); }); - it('should not change the state in response to the INITIAL_COLLAPSE action when the state has already been set for this filter', () => { + it('should not change the state in response to the INITIALIZE action with isOpenByDefault to false when the state has already been set for this filter', () => { const state = {}; state[filterName1] = { filterCollapsed: false, page: 1 }; - const action = new SearchFilterInitialCollapseAction(filterName1); + const filterConfig = { isOpenByDefault: true, name: filterName1 } as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState).toEqual(state); }); - it('should not change the state in response to the INITIAL_EXPAND action when the state has already been set for this filter', () => { + it('should not change the state in response to the INITIALIZE action with isOpenByDefault to true when the state has already been set for this filter', () => { const state = {}; state[filterName1] = { filterCollapsed: true, page: 1 }; - const action = new SearchFilterInitialExpandAction(filterName1); + const filterConfig = { isOpenByDefault: false, name: filterName1 } as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState).toEqual(state); }); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts index f7e064fcc7..7102c8c9bc 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts @@ -1,5 +1,4 @@ -import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions'; -import { isEmpty } from '../../../shared/empty.util'; +import { SearchFilterAction, SearchFilterActionTypes, SearchFilterInitializeAction } from './search-filter.actions'; /** * Interface that represents the state for a single filters @@ -28,27 +27,14 @@ export function filterReducer(state = initialState, action: SearchFilterAction): switch (action.type) { - case SearchFilterActionTypes.INITIAL_COLLAPSE: { - if (isEmpty(state) || isEmpty(state[action.filterName])) { - return Object.assign({}, state, { - [action.filterName]: { - filterCollapsed: true, - page: 1 - } - }); - } - return state; - } - - case SearchFilterActionTypes.INITIAL_EXPAND: { - if (isEmpty(state) || isEmpty(state[action.filterName])) { - return Object.assign({}, state, { - [action.filterName]: { - filterCollapsed: false, - page: 1 - } - }); - } + case SearchFilterActionTypes.INITIALIZE: { + const initAction = (action as SearchFilterInitializeAction); + return Object.assign({}, state, { + [action.filterName]: { + filterCollapsed: !initAction.initiallyExpanded, + page: 1 + } + }); return state; } diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts index 156e8d47ea..e317a27698 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts @@ -5,16 +5,17 @@ import { SearchFilterDecrementPageAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, + SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; import { SearchFiltersState } from './search-filter.reducer'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { FilterType } from '../../search-service/filter-type.model'; +import { SearchFixedFilterService } from './search-fixed-filter.service'; import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; import { of as observableOf } from 'rxjs'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; describe('SearchFilterService', () => { let service: SearchFilterService; @@ -26,6 +27,12 @@ describe('SearchFilterService', () => { isOpenByDefault: false, pageSize: 2 }); + + const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } + } as SearchFixedFilterService const value1 = 'random value'; // const value2 = 'another value'; const store: Store = jasmine.createSpyObj('store', { @@ -45,11 +52,15 @@ describe('SearchFilterService', () => { }, addQueryParameterValue: (param: string, value: string) => { }, + getQueryParameterValue: (param: string) => { + }, getQueryParameterValues: (param: string) => { return observableOf({}); }, getQueryParamsWithPrefix: (param: string) => { return observableOf({}); + }, + getRouteParameterValue: (param: string) => { } /* tslint:enable:no-empty */ }; @@ -59,26 +70,16 @@ describe('SearchFilterService', () => { }; beforeEach(() => { - service = new SearchFilterService(store, routeServiceStub); + service = new SearchFilterService(store, routeServiceStub, mockFixedFilterService); }); - describe('when the initialCollapse method is triggered', () => { + describe('when the initializeFilter method is triggered', () => { beforeEach(() => { - service.initialCollapse(mockFilterConfig.name); + service.initializeFilter(mockFilterConfig); }); - it('SearchFilterInitialCollapseAction should be dispatched to the store', () => { - expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialCollapseAction(mockFilterConfig.name)); - }); - }); - - describe('when the initialExpand method is triggered', () => { - beforeEach(() => { - service.initialExpand(mockFilterConfig.name); - }); - - it('SearchFilterInitialExpandAction should be dispatched to the store', () => { - expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialExpandAction(mockFilterConfig.name)); + it('SearchFilterInitializeAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitializeAction(mockFilterConfig)); }); }); @@ -179,4 +180,113 @@ describe('SearchFilterService', () => { }); }); + describe('when the getCurrentScope method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue'); + service.getCurrentScope(); + }); + + it('should call getQueryParameterValue on the route service with scope', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('scope'); + }); + }); + + describe('when the getCurrentQuery method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue'); + service.getCurrentQuery(); + }); + + it('should call getQueryParameterValue on the route service with query', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('query'); + }); + }); + + describe('when the getCurrentPagination method is called', () => { + let result; + const mockReturn = 5; + + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue').and.returnValue(observableOf(mockReturn)); + result = service.getCurrentPagination(); + }); + + it('should call getQueryParameterValue on the route service with page', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('page'); + }); + + it('should call getQueryParameterValue on the route service with pageSize', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('pageSize'); + }); + + it('should return an observable containing the correct pagination', () => { + result.subscribe((pagination) => { + expect(pagination.currentPage).toBe(mockReturn); + expect(pagination.pageSize).toBe(mockReturn); + }); + }); + }); + + describe('when the getCurrentSort method is called', () => { + let result; + const field = 'author'; + const direction = SortDirection.ASC; + + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue').and.returnValue(observableOf(undefined)); + result = service.getCurrentSort(new SortOptions(field, direction)); + }); + + it('should call getQueryParameterValue on the route service with sortDirection', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('sortDirection'); + }); + + it('should call getQueryParameterValue on the route service with sortField', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('sortField'); + }); + + it('should return an observable containing the correct sortOptions', () => { + result.subscribe((sort) => { + expect(sort.field).toBe(field); + expect(sort.direction).toBe(direction); + }); + }); + }); + + describe('when the getCurrentFilters method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParamsWithPrefix'); + service.getCurrentFilters(); + }); + + it('should call getQueryParamsWithPrefix on the route service with prefix \'f.\'', () => { + expect(routeServiceStub.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + }); + }); + + describe('when the getCurrentFixedFilter method is called', () => { + const filter = 'filter'; + + beforeEach(() => { + spyOn(routeServiceStub, 'getRouteParameterValue').and.returnValue(observableOf(filter)); + spyOn(mockFixedFilterService, 'getQueryByFilterName').and.returnValue(observableOf(filter)); + service.getCurrentFixedFilter().subscribe(); + }); + + it('should call getQueryByFilterName on the fixed-filter service with the correct filter', () => { + expect(mockFixedFilterService.getQueryByFilterName).toHaveBeenCalledWith(filter); + }); + }); + + describe('when the getCurrentView method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue'); + service.getCurrentView(); + }); + + it('should call getQueryParameterValue on the route service with view', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('view'); + }); + }); + }); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index bf21eab367..4b12417084 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -1,6 +1,6 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators'; import { Injectable, InjectionToken } from '@angular/core'; -import { map } from 'rxjs/operators'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { @@ -8,19 +8,26 @@ import { SearchFilterDecrementPageAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, + SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { RouteService } from '../../../shared/services/route.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchOptions } from '../../search-options.model'; +import { PaginatedSearchOptions } from '../../paginated-search-options.model'; +import { SearchFixedFilterService } from './search-fixed-filter.service'; import { Params } from '@angular/router'; - +import * as postcss from 'postcss'; +import prefix = postcss.vendor.prefix; +// const spy = create(); const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); +export const IN_PLACE_SEARCH: InjectionToken = new InjectionToken('inPlaceSearch'); /** * Service that performs all actions that have to do with search filters and facets @@ -29,8 +36,8 @@ export const FILTER_CONFIG: InjectionToken = new InjectionTo export class SearchFilterService { constructor(private store: Store, - private routeService: RouteService - ) { + private routeService: RouteService, + private fixedFilterService: SearchFixedFilterService) { } /** @@ -52,6 +59,81 @@ export class SearchFilterService { return this.routeService.hasQueryParam(paramName); } + /** + * Fetch the current active scope from the query parameters + * @returns {Observable} + */ + getCurrentScope() { + return this.routeService.getQueryParameterValue('scope'); + } + + /** + * Fetch the current query from the query parameters + * @returns {Observable} + */ + getCurrentQuery() { + return this.routeService.getQueryParameterValue('query'); + } + + /** + * Fetch the current pagination from query parameters 'page' and 'pageSize' + * and combine them with a given pagination + * @param pagination Pagination options to combine the query parameters with + * @returns {Observable} + */ + getCurrentPagination(pagination: any = {}): Observable { + const page$ = this.routeService.getQueryParameterValue('page'); + const size$ = this.routeService.getQueryParameterValue('pageSize'); + return observableCombineLatest(page$, size$).pipe(map(([page, size]) => { + return Object.assign(new PaginationComponentOptions(), pagination, { + currentPage: page || 1, + pageSize: size || pagination.pageSize + }); + })) + } + + /** + * Fetch the current sorting options from query parameters 'sortDirection' and 'sortField' + * and combine them with given sorting options + * @param {SortOptions} defaultSort Sorting options to combine the query parameters with + * @returns {Observable} + */ + getCurrentSort(defaultSort: SortOptions): Observable { + const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection'); + const sortField$ = this.routeService.getQueryParameterValue('sortField'); + return observableCombineLatest(sortDirection$, sortField$).pipe(map(([sortDirection, sortField]) => { + const field = sortField || defaultSort.field; + const direction = SortDirection[sortDirection] || defaultSort.direction; + return new SortOptions(field, direction) + } + )) + } + + /** + * Fetch the current active filters from the query parameters + * @returns {Observable} + */ + getCurrentFilters() { + return this.routeService.getQueryParamsWithPrefix('f.'); + } + + /** + * Fetch the current active fixed filter from the route parameters and return the query by filter name + * @returns {Observable} + */ + getCurrentFixedFilter(): Observable { + const filter: Observable = this.routeService.getRouteParameterValue('filter'); + return filter.pipe(mergeMap((f) => this.fixedFilterService.getQueryByFilterName(f))); + } + + /** + * Fetch the current view from the query parameters + * @returns {Observable} + */ + getCurrentView() { + return this.routeService.getQueryParameterValue('view'); + } + /** * Requests the active filter values set for a given filter * @param {SearchFilterConfig} filterConfig The configuration for which the filters are active @@ -60,9 +142,8 @@ export class SearchFilterService { getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable { const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName); const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe( - map((params: Params) => [].concat(...Object.values(params))) + map((params: Params) => [].concat(...Object.values(params))), ); - return observableCombineLatest(values$, prefixValues$).pipe( map(([values, prefixValues]) => { if (isNotEmpty(values)) { @@ -88,13 +169,14 @@ export class SearchFilterService { } else { return false; } - }) + }), + distinctUntilChanged() ); } /** * Request the current page of a given filter - * @param {string} filterName The filtername for which the page state is checked + * @param {string} filterName The filter name for which the page state is checked * @returns {Observable} Emits the current page state of the given filter, if it's unavailable, return 1 */ getPage(filterName: string): Observable { @@ -106,7 +188,8 @@ export class SearchFilterService { } else { return 1; } - })); + }), + distinctUntilChanged()); } /** @@ -134,19 +217,11 @@ export class SearchFilterService { } /** - * Dispatches an initial collapse action to the store for a given filter - * @param {string} filterName The filter for which the action is dispatched + * Dispatches an initialize action to the store for a given filter + * @param {SearchFilterConfig} filter The filter for which the action is dispatched */ - public initialCollapse(filterName: string): void { - this.store.dispatch(new SearchFilterInitialCollapseAction(filterName)); - } - - /** - * Dispatches an initial expand action to the store for a given filter - * @param {string} filterName The filter for which the action is dispatched - */ - public initialExpand(filterName: string): void { - this.store.dispatch(new SearchFilterInitialExpandAction(filterName)); + public initializeFilter(filter: SearchFilterConfig): void { + this.store.dispatch(new SearchFilterInitializeAction(filter)); } /** diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts new file mode 100644 index 0000000000..3207345564 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts @@ -0,0 +1,60 @@ +import { SearchFixedFilterService } from './search-fixed-filter.service'; +import { RouteService } from '../../../shared/services/route.service'; +import { RequestService } from '../../../core/data/request.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { of as observableOf } from 'rxjs'; +import { RequestEntry } from '../../../core/data/request.reducer'; +import { FilteredDiscoveryQueryResponse, RestResponse } from '../../../core/cache/response.models'; + +describe('SearchFixedFilterService', () => { + let service: SearchFixedFilterService; + + const filterQuery = 'filter:query'; + + const routeServiceStub = {} as RouteService; + const requestServiceStub = Object.assign({ + /* tslint:disable:no-empty */ + configure: () => {}, + /* tslint:enable:no-empty */ + generateRequestId: () => 'fake-id', + getByUUID: () => observableOf(Object.assign(new RequestEntry(), { + response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK') + })) + }) as RequestService; + const halServiceStub = Object.assign(new HALEndpointService(requestServiceStub, undefined), { + getEndpoint: () => observableOf('fake-url') + }); + + beforeEach(() => { + service = new SearchFixedFilterService(routeServiceStub, requestServiceStub, halServiceStub); + }); + + describe('when getQueryByFilterName is called with a filterName', () => { + it('should return the filter query', () => { + service.getQueryByFilterName('filter').subscribe((query) => { + expect(query).toBe(filterQuery); + }); + }); + }); + + describe('when getQueryByFilterName is called without a filterName', () => { + it('should return undefined', () => { + service.getQueryByFilterName(undefined).subscribe((query) => { + expect(query).toBeUndefined(); + }); + }); + }); + + describe('when getQueryByRelations is called', () => { + const relationType = 'isRelationOf'; + const itemUUID = 'c5b277e6-2477-48bb-8993-356710c285f3'; + + it('should contain the relationType and itemUUID', () => { + const query = service.getQueryByRelations(relationType, itemUUID); + expect(query.length).toBeGreaterThan(relationType.length + itemUUID.length); + expect(query).toContain(relationType); + expect(query).toContain(itemUUID); + }); + }); + +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts new file mode 100644 index 0000000000..7d59e5a446 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@angular/core'; +import { flatMap, map } from 'rxjs/operators'; +import { Observable , of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { GetRequest, RestRequest } from '../../../core/data/request.models'; +import { RequestService } from '../../../core/data/request.service'; +import { ResponseParsingService } from '../../../core/data/parsing.service'; +import { GenericConstructor } from '../../../core/shared/generic-constructor'; +import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service'; +import { hasValue } from '../../../shared/empty.util'; +import { configureRequest, getResponseFromEntry } from '../../../core/shared/operators'; +import { RouteService } from '../../../shared/services/route.service'; +import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models'; + +/** + * Service for performing actions on the filtered-discovery-pages REST endpoint + */ +@Injectable() +export class SearchFixedFilterService { + private queryByFilterPath = 'filtered-discovery-pages'; + + constructor(private routeService: RouteService, + protected requestService: RequestService, + private halService: HALEndpointService) { + + } + + /** + * Get the filter query for a certain filter by name + * @param {string} filterName Name of the filter + * @returns {Observable} Filter query + */ + getQueryByFilterName(filterName: string): Observable { + if (hasValue(filterName)) { + const requestUuid = this.requestService.generateRequestId(); + this.halService.getEndpoint(this.queryByFilterPath).pipe( + map((url: string) => { + url += ('/' + filterName); + const request = new GetRequest(requestUuid, url); + return Object.assign(request, { + getResponseParser(): GenericConstructor { + return FilteredDiscoveryPageResponseParsingService; + } + }); + }), + configureRequest(this.requestService) + ).subscribe(); + + // get search results from response cache + const filterQuery: Observable = this.requestService.getByUUID(requestUuid).pipe( + getResponseFromEntry(), + map((response: FilteredDiscoveryQueryResponse) => + response.filterQuery + )); + return filterQuery; + } + return observableOf(undefined); + } + + /** + * Get the query for looking up items by relation type + * @param {string} relationType Relation type + * @param {string} itemUUID Item UUID + * @returns {string} Query + */ + getQueryByRelations(relationType: string, itemUUID: string): string { + return `query=relation.${relationType}:${itemUUID}`; + } + + /** + * Get the filter for a relation with the item's UUID + * @param relationType The type of relation e.g. 'isAuthorOfPublication' + * @param itemUUID The item's UUID + */ + getFilterByRelation(relationType: string, itemUUID: string): string { + return `f.${relationType}=${itemUUID}`; + } + +} diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html index 812f543716..ac2a72f4b6 100644 --- a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html @@ -1,24 +1,9 @@
- - - {{value}} - +
diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss index 9ec0f61541..d8bb1ff1b3 100644 --- a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss @@ -2,15 +2,6 @@ @import '../../../../../styles/mixins.scss'; .filters { - a { - color: $body-color; - &:hover, &focus { - text-decoration: none; - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html index 352c1710c0..cad31e7f0f 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html @@ -24,16 +24,7 @@
diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss index c45302b162..caaef5985e 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -3,17 +3,6 @@ .filters { - a { - color: $link-color; - &:hover { - text-decoration: underline; - color: $link-hover-color; - - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts index 6f3450e18e..119f3f92a9 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { FilterType } from '../../../search-service/filter-type.model'; import { FacetValue } from '../../../search-service/facet-value.model'; @@ -18,7 +18,8 @@ import { PageInfo } from '../../../../core/shared/page-info.model'; import { SearchRangeFilterComponent } from './search-range-filter.component'; import { RouteService } from '../../../../shared/services/route.service'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; -import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub'; describe('SearchRangeFilterComponent', () => { let comp: SearchRangeFilterComponent; @@ -41,14 +42,17 @@ describe('SearchRangeFilterComponent', () => { }); const values: FacetValue[] = [ { + label: value1, value: value1, count: 52, search: '' }, { + label: value2, value: value2, count: 20, search: '' }, { + label: value3, value: value3, count: 5, search: '' @@ -73,9 +77,8 @@ describe('SearchRangeFilterComponent', () => { { provide: FILTER_CONFIG, useValue: mockFilterConfig }, { provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} }, { provide: RouteService, useValue: {getQueryParameterValue: () => observableOf({})} }, - { provide: SearchConfigurationService, useValue: { - searchOptions: observableOf({}) } - }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + { provide: IN_PLACE_SEARCH, useValue: false }, { provide: SearchFilterService, useValue: { getSelectedValuesForFilter: () => selectedValues, @@ -106,16 +109,6 @@ describe('SearchRangeFilterComponent', () => { fixture.detectChanges(); }); - describe('when the getChangeParams method is called wih a value', () => { - it('should return the selectedValue list with the new parameter value', () => { - const result$ = comp.getChangeParams(value3); - result$.subscribe((result) => { - expect(result[mockFilterConfig.paramName + minSuffix]).toEqual(['1990']); - expect(result[mockFilterConfig.paramName + maxSuffix]).toEqual(['1992']); - }); - }); - }); - describe('when the onSubmit method is called with data', () => { const searchUrl = '/search/path'; // const data = { [mockFilterConfig.paramName + minSuffix]: '1900', [mockFilterConfig.paramName + maxSuffix]: '1950' }; @@ -126,7 +119,7 @@ describe('SearchRangeFilterComponent', () => { }); it('should call navigate on the router with the right searchlink and parameters', () => { - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { + expect(router.navigate).toHaveBeenCalledWith(searchUrl.split('/'), { queryParams: { [mockFilterConfig.paramName + minSuffix]: [1900], [mockFilterConfig.paramName + maxSuffix]: [1950] diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index 6cb04c6c1f..95d7441184 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -1,9 +1,4 @@ -import { - of as observableOf, - combineLatest as observableCombineLatest, - Observable, - Subscription -} from 'rxjs'; +import { combineLatest as observableCombineLatest, Subscription } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { isPlatformBrowser } from '@angular/common'; import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; @@ -15,24 +10,35 @@ import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchService } from '../../../search-service/search.service'; import { Router } from '@angular/router'; import * as moment from 'moment'; import { RouteService } from '../../../../shared/services/route.service'; import { hasValue } from '../../../../shared/empty.util'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; + +/** + * The suffix for a range filters' minimum in the frontend URL + */ +export const RANGE_FILTER_MIN_SUFFIX = '.min'; + +/** + * The suffix for a range filters' maximum in the frontend URL + */ +export const RANGE_FILTER_MAX_SUFFIX = '.max'; + +/** + * The date formats that are possible to appear in a date filter + */ +const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD']; /** * This component renders a simple item page. * The route parameter 'id' is used to request the item it represents. * All fields of the item that should be displayed, are defined in its template. */ -const minSuffix = '.min'; -const maxSuffix = '.max'; -const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD']; -const rangeDelimiter = '-'; - @Component({ selector: 'ds-search-range-filter', styleUrls: ['./search-range-filter.component.scss'], @@ -67,13 +73,14 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple constructor(protected searchService: SearchService, protected filterService: SearchFilterService, - protected searchConfigService: SearchConfigurationService, protected router: Router, protected rdbs: RemoteDataBuildService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + @Inject(IN_PLACE_SEARCH) public inPlaceSearch: boolean, @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig, @Inject(PLATFORM_ID) private platformId: any, private route: RouteService) { - super(searchService, filterService, searchConfigService, rdbs, router, filterConfig); + super(searchService, filterService, rdbs, router, searchConfigService, inPlaceSearch, filterConfig); } @@ -85,8 +92,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple super.ngOnInit(); this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min; this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max; - const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).pipe(startWith(undefined)); - const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).pipe(startWith(undefined)); + const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX).pipe(startWith(undefined)); + const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX).pipe(startWith(undefined)); this.sub = observableCombineLatest(iniMin, iniMax).pipe( map(([min, max]) => { const minimum = hasValue(min) ? min : this.min; @@ -96,34 +103,17 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple ).subscribe((minmax) => this.range = minmax); } - /** - * Calculates the parameters that should change if a given values for this range filter would be changed - * @param {string} value The values that are changed for this filter - * @returns {Observable} The changed filter parameters - */ - getChangeParams(value: string) { - const parts = value.split(rangeDelimiter); - const min = parts.length > 1 ? parts[0].trim() : value; - const max = parts.length > 1 ? parts[1].trim() : value; - return observableOf( - { - [this.filterConfig.paramName + minSuffix]: [min], - [this.filterConfig.paramName + maxSuffix]: [max], - page: 1 - }); - } - /** * Submits new custom range values to the range filter from the widget */ onSubmit() { const newMin = this.range[0] !== this.min ? [this.range[0]] : null; const newMax = this.range[1] !== this.max ? [this.range[1]] : null; - this.router.navigate([this.getSearchLink()], { + this.router.navigate(this.getSearchLinkParts(), { queryParams: { - [this.filterConfig.paramName + minSuffix]: newMin, - [this.filterConfig.paramName + maxSuffix]: newMax + [this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: newMin, + [this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: newMax }, queryParamsHandling: 'merge' }); @@ -148,8 +138,4 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple this.sub.unsubscribe(); } } - - out(call) { - console.log(call); - } } diff --git a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html index fcc2393b93..a4f4fb5ee8 100644 --- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html @@ -1,26 +1,9 @@
- - - {{value}} - - + +
@@ -40,6 +23,5 @@ (submitSuggestion)="onSubmit($event)" (clickSuggestion)="onClick($event)" (findSuggestions)="findSuggestions($event)" - ngDefaultControl - > + ngDefaultControl>
diff --git a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss index 33e354f2d8..d8bb1ff1b3 100644 --- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss @@ -2,15 +2,6 @@ @import '../../../../../styles/mixins.scss'; .filters { - a { - color: $body-color; - &:hover, &focus { - text-decoration: none; - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filters.component.html b/src/app/+search-page/search-filters/search-filters.component.html index 0522c1fba0..05f4a693c2 100644 --- a/src/app/+search-page/search-filters/search-filters.component.html +++ b/src/app/+search-page/search-filters/search-filters.component.html @@ -1,7 +1,7 @@

{{"search.filters.head" | translate}}

-
- +
+
-{{"search.filters.reset" | translate}} \ No newline at end of file +{{"search.filters.reset" | translate}} diff --git a/src/app/+search-page/search-filters/search-filters.component.spec.ts b/src/app/+search-page/search-filters/search-filters.component.spec.ts index db21fc8a69..dc883cd290 100644 --- a/src/app/+search-page/search-filters/search-filters.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filters.component.spec.ts @@ -7,13 +7,15 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SearchFilterService } from './search-filter/search-filter.service'; import { SearchFiltersComponent } from './search-filters.component'; import { SearchService } from '../search-service/search.service'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; import { of as observableOf } from 'rxjs'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; describe('SearchFiltersComponent', () => { let comp: SearchFiltersComponent; let fixture: ComponentFixture; let searchService: SearchService; + const searchServiceStub = { /* tslint:disable:no-empty */ getConfig: () => @@ -30,17 +32,13 @@ describe('SearchFiltersComponent', () => { [] }; - const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', { - getCurrentFrontendFilters: observableOf({}) - }); - beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], declarations: [SearchFiltersComponent], providers: [ { provide: SearchService, useValue: searchServiceStub }, - { provide: SearchConfigurationService, useValue: searchConfigServiceStub }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, { provide: SearchFilterService, useValue: searchFiltersStub }, ], diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts index f16faff1f3..e970647747 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -1,14 +1,15 @@ -import { Observable, of as observableOf } from 'rxjs'; +import { Component, Inject, Input, OnInit } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; -import { filter, map, mergeMap, startWith, switchMap } from 'rxjs/operators'; -import { Component } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { RemoteData } from '../../core/data/remote-data'; import { SearchFilterConfig } from '../search-service/search-filter-config.model'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; -import { isNotEmpty } from '../../shared/empty.util'; import { SearchFilterService } from './search-filter/search-filter.service'; import { getSucceededRemoteData } from '../../core/shared/operators'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-filters', @@ -19,7 +20,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; /** * This component represents the part of the search sidebar that contains filters. */ -export class SearchFiltersComponent { +export class SearchFiltersComponent implements OnInit { /** * An observable containing configuration about which filters are shown and how they are shown */ @@ -31,48 +32,51 @@ export class SearchFiltersComponent { */ clearParams; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Initialize instance variables * @param {SearchService} searchService * @param {SearchConfigurationService} searchConfigService * @param {SearchFilterService} filterService */ - constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) { - this.filters = searchService.getConfig().pipe(getSucceededRemoteData()); - this.clearParams = searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => { + constructor( + private searchService: SearchService, + private filterService: SearchFilterService, + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { + + } + + ngOnInit(): void { + + this.filters = this.searchConfigService.searchOptions.pipe( + switchMap((options) => this.searchService.getConfig(options.scope, options.configuration).pipe(getSucceededRemoteData())) + ); + + this.clearParams = this.searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => { Object.keys(filters).forEach((f) => filters[f] = null); return filters; })); } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } /** - * Check if a given filter is supposed to be shown or not - * @param {SearchFilterConfig} filter The filter to check for - * @returns {Observable} Emits true whenever a given filter config should be shown + * Prevent unnecessary rerendering */ - isActive(filterConfig: SearchFilterConfig): Observable { - return this.filterService.getSelectedValuesForFilter(filterConfig).pipe( - mergeMap((isActive) => { - if (isNotEmpty(isActive)) { - return observableOf(true); - } else { - return this.searchConfigService.searchOptions.pipe( - switchMap((options) => { - return this.searchService.getFacetValuesFor(filterConfig, 1, options).pipe( - filter((RD) => !RD.isLoading), - map((valuesRD) => { - return valuesRD.payload.totalElements > 0 - }),) - } - )) - } - }),startWith(true),); + trackUpdate(index, config: SearchFilterConfig) { + return config ? config.name : undefined; } + } diff --git a/src/app/+search-page/search-labels/search-labels.component.html b/src/app/+search-page/search-labels/search-labels.component.html index 61a5618dad..cac81e8717 100644 --- a/src/app/+search-page/search-labels/search-labels.component.html +++ b/src/app/+search-page/search-labels/search-labels.component.html @@ -2,11 +2,11 @@ diff --git a/src/app/+search-page/search-labels/search-labels.component.spec.ts b/src/app/+search-page/search-labels/search-labels.component.spec.ts index 81fa5b5df8..d28698764c 100644 --- a/src/app/+search-page/search-labels/search-labels.component.spec.ts +++ b/src/app/+search-page/search-labels/search-labels.component.spec.ts @@ -9,7 +9,8 @@ import { SearchServiceStub } from '../../shared/testing/search-service-stub'; import { Observable, of as observableOf } from 'rxjs'; import { Params } from '@angular/router'; import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; describe('SearchLabelsComponent', () => { let comp: SearchLabelsComponent; @@ -20,8 +21,11 @@ describe('SearchLabelsComponent', () => { const field1 = 'author'; const field2 = 'subject'; - const value1 = 'TestAuthor'; + const value1 = 'Test, Author'; + const normValue1 = 'Test, Author'; const value2 = 'TestSubject'; + const value3 = 'Test, Authority,authority'; + const normValue3 = 'Test, Authority'; const filter1 = [field1, value1]; const filter2 = [field2, value2]; const mockFilters = [ @@ -35,7 +39,8 @@ describe('SearchLabelsComponent', () => { declarations: [SearchLabelsComponent, ObjectKeysPipe], providers: [ { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, - { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } + // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchLabelsComponent, { @@ -65,4 +70,16 @@ describe('SearchLabelsComponent', () => { }); }) }); + + describe('when normalizeFilterValue is called', () => { + it('should return properly filter value', () => { + let result: string; + + result = comp.normalizeFilterValue(value1); + expect(result).toBe(normValue1); + + result = comp.normalizeFilterValue(value3); + expect(result).toBe(normValue3); + }) + }); }); diff --git a/src/app/+search-page/search-labels/search-labels.component.ts b/src/app/+search-page/search-labels/search-labels.component.ts index 08e07cce3d..104ed5b08b 100644 --- a/src/app/+search-page/search-labels/search-labels.component.ts +++ b/src/app/+search-page/search-labels/search-labels.component.ts @@ -1,10 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, Inject, Input } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { Observable } from 'rxjs'; import { Params } from '@angular/router'; import { map } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-labels', @@ -21,10 +22,17 @@ export class SearchLabelsComponent { */ appliedFilters: Observable; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Initialize the instance variable */ - constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService) { + constructor( + private searchService: SearchService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters(); } @@ -48,9 +56,25 @@ export class SearchLabelsComponent { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } + + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Strips authority operator from filter value + * e.g. 'test ,authority' => 'test' + * + * @param value + */ + normalizeFilterValue(value: string) { + // const pattern = /,[^,]*$/g; + const pattern = /,authority*$/g; + return value.replace(pattern, ''); + } } diff --git a/src/app/+search-page/search-options.model.ts b/src/app/+search-page/search-options.model.ts index 123cf950f8..2b18854e1e 100644 --- a/src/app/+search-page/search-options.model.ts +++ b/src/app/+search-page/search-options.model.ts @@ -3,21 +3,27 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import 'core-js/library/fn/object/entries'; import { SearchFilter } from './search-filter.model'; import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; +import { SetViewMode } from '../shared/view-mode'; /** * This model class represents all parameters needed to request information about a certain search request */ export class SearchOptions { + configuration?: string; + view?: SetViewMode = SetViewMode.List; scope?: string; query?: string; dsoType?: DSpaceObjectType; - filters?: SearchFilter[]; + filters?: any; + fixedFilter?: any; - constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[]}) { + constructor(options: {configuration?: string, scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any}) { + this.configuration = options.configuration; this.scope = options.scope; this.query = options.query; this.dsoType = options.dsoType; this.filters = options.filters; + this.fixedFilter = options.fixedFilter; } /** @@ -27,7 +33,12 @@ export class SearchOptions { * @returns {string} URL with all search options and passed arguments as query parameters */ toRestUrl(url: string, args: string[] = []): string { - + if (isNotEmpty(this.configuration)) { + args.push(`configuration=${this.configuration}`); + } + if (isNotEmpty(this.fixedFilter)) { + args.push(this.fixedFilter); + } if (isNotEmpty(this.query)) { args.push(`query=${this.query}`); } @@ -39,7 +50,10 @@ export class SearchOptions { } if (isNotEmpty(this.filters)) { this.filters.forEach((filter: SearchFilter) => { - filter.values.forEach((value) => args.push(`${filter.key}=${value},${filter.operator}`)); + filter.values.forEach((value) => { + const filterValue = value.includes(',') ? `${value}` : `${value},${filter.operator}`; + args.push(`${filter.key}=${filterValue}`) + }); }); } if (isNotEmpty(args)) { diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index 65cca99a34..8c138c0d52 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -2,11 +2,14 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { SearchPageComponent } from './search-page.component'; +import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { FilteredSearchPageGuard } from './filtered-search-page.guard'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', component: SearchPageComponent, data: { title: 'search.title' } } + { path: '', component: SearchPageComponent, data: { title: 'search.title' } }, + { path: ':filter', component: FilteredSearchPageComponent, canActivate: [FilteredSearchPageGuard], data: { title: 'search.' }} ]) ] }) diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 6476f8bd68..c11e863429 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -1,16 +1,17 @@
- -
- +
+ + [scopes]="(scopeListRD$ | async)" + [inPlaceSearch]="inPlaceSearch"> - +
- +
+ [searchConfig]="searchOptions$ | async" + [fixedFilter]="fixedFilter$ | async" + [disableHeader]="!searchEnabled">
diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index 1991cf8f1b..88c7c693d3 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -20,91 +20,142 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { RemoteData } from '../core/data/remote-data'; +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../shared/services/route.service'; +import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; +import { PaginatedSearchOptions } from './paginated-search-options.model'; +import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; + +let comp: SearchPageComponent; +let fixture: ComponentFixture; +let searchServiceObject: SearchService; +let searchConfigurationServiceObject: SearchConfigurationService; +const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + select: observableOf(true) +}); +const pagination: PaginationComponentOptions = new PaginationComponentOptions(); +pagination.id = 'search-results-pagination'; +pagination.currentPage = 1; +pagination.pageSize = 10; +const sort: SortOptions = new SortOptions('score', SortDirection.DESC); +const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); +const searchServiceStub = jasmine.createSpyObj('SearchService', { + search: mockResults, + getSearchLink: '/search', + getScopes: observableOf(['test-scope']) +}); +const configurationParam = 'default'; +const queryParam = 'test query'; +const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; +const fixedFilter = 'fixed filter'; +const paginatedSearchOptions = new PaginatedSearchOptions({ + configuration: configurationParam, + query: queryParam, + scope: scopeParam, + fixedFilter: fixedFilter, + pagination, + sort +}); +const activatedRouteStub = { + snapshot: { + queryParamMap: new Map([ + ['query', queryParam], + ['scope', scopeParam] + ]) + }, + queryParams: observableOf({ + query: queryParam, + scope: scopeParam + }) +}; +const sidebarService = { + isCollapsed: observableOf(true), + collapse: () => this.isCollapsed = observableOf(true), + expand: () => this.isCollapsed = observableOf(false) +}; + +const routeServiceStub = { + getRouteParameterValue: () => { + return observableOf(''); + }, + getQueryParameterValue: () => { + return observableOf('') + }, + getQueryParamsWithPrefix: () => { + return observableOf('') + } +}; +const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } +} as SearchFixedFilterService; + +export function configureSearchComponentTestingModule(compType) { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()], + declarations: [compType], + providers: [ + { provide: SearchService, useValue: searchServiceStub }, + { + provide: CommunityDataService, + useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll']) + }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: RouteService, useValue: routeServiceStub }, + { + provide: Store, useValue: store + }, + { + provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', + { + isXs: observableOf(true), + isSm: observableOf(false), + isXsOrSm: observableOf(true) + }) + }, + { + provide: SearchSidebarService, + useValue: sidebarService + }, + { + provide: SearchFilterService, + useValue: {} + }, + { + provide: SearchFixedFilterService, + useValue: mockFixedFilterService + }, + { + provide: SearchConfigurationService, + useValue: { + paginatedSearchOptions: hot('a', { + a: paginatedSearchOptions + }), + getCurrentScope: (a) => observableOf('test-id'), + /* tslint:disable:no-empty */ + updateFixedFilter: (newFilter) => { + } + /* tslint:enable:no-empty */ + } + }, + { + provide: SEARCH_CONFIG_SERVICE, + useValue: new SearchConfigurationServiceStub() + }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(compType, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); +} describe('SearchPageComponent', () => { - let comp: SearchPageComponent; - let fixture: ComponentFixture; - let searchServiceObject: SearchService; - const store: Store = jasmine.createSpyObj('store', { - /* tslint:disable:no-empty */ - dispatch: {}, - /* tslint:enable:no-empty */ - select: observableOf(true) - }); - const pagination: PaginationComponentOptions = new PaginationComponentOptions(); - pagination.id = 'search-results-pagination'; - pagination.currentPage = 1; - pagination.pageSize = 10; - const sort: SortOptions = new SortOptions('score', SortDirection.DESC); - const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); - const searchServiceStub = jasmine.createSpyObj('SearchService', { - search: mockResults, - getSearchLink: '/search', - getScopes: observableOf(['test-scope']) - }); - const queryParam = 'test query'; - const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; - const paginatedSearchOptions = { - query: queryParam, - scope: scopeParam, - pagination, - sort - }; - const activatedRouteStub = { - queryParams: observableOf({ - query: queryParam, - scope: scopeParam - }) - }; - const sidebarService = { - isCollapsed: observableOf(true), - collapse: () => this.isCollapsed = observableOf(true), - expand: () => this.isCollapsed = observableOf(false) - }; - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()], - declarations: [SearchPageComponent], - providers: [ - { provide: SearchService, useValue: searchServiceStub }, - { - provide: CommunityDataService, - useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll']) - }, - { provide: ActivatedRoute, useValue: activatedRouteStub }, - { - provide: Store, useValue: store - }, - { - provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', - { - isXs: observableOf(true), - isSm: observableOf(false), - isXsOrSm: observableOf(true) - }) - }, - { - provide: SearchSidebarService, - useValue: sidebarService - }, - { - provide: SearchFilterService, - useValue: {} - }, { - provide: SearchConfigurationService, - useValue: { - paginatedSearchOptions: hot('a', { - a: paginatedSearchOptions - }), - getCurrentScope: (a) => observableOf('test-id') - } - }, - ], - schemas: [NO_ERRORS_SCHEMA] - }).overrideComponent(SearchPageComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default } - }).compileComponents(); + configureSearchComponentTestingModule(SearchPageComponent); })); beforeEach(() => { @@ -112,25 +163,21 @@ describe('SearchPageComponent', () => { comp = fixture.componentInstance; // SearchPageComponent test instance fixture.detectChanges(); searchServiceObject = (comp as any).service; + searchConfigurationServiceObject = (comp as any).searchConfigService; + }); + + afterEach(() => { + comp = null; + searchServiceObject = null; + searchConfigurationServiceObject = null; }); it('should get the scope and query from the route parameters', () => { + + searchConfigurationServiceObject.paginatedSearchOptions.next(paginatedSearchOptions); expect(comp.searchOptions$).toBeObservable(cold('b', { b: paginatedSearchOptions })); - }); - - describe('when the closeSidebar event is emitted clicked in mobile view', () => { - - beforeEach(() => { - spyOn(comp, 'closeSidebar'); - const closeSidebarButton = fixture.debugElement.query(By.css('#search-sidebar-sm')); - closeSidebarButton.triggerEventHandler('toggleSidebar', null); - }); - - it('should trigger the closeSidebar function', () => { - expect(comp.closeSidebar).toHaveBeenCalled(); - }); }); @@ -177,4 +224,4 @@ describe('SearchPageComponent', () => { }); }); -}) +}); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 816e3d67bf..2b343ac584 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; +import { Observable , Subscription , BehaviorSubject } from 'rxjs'; import { switchMap, } from 'rxjs/operators'; import { PaginatedList } from '../core/data/paginated-list'; import { RemoteData } from '../core/data/remote-data'; @@ -7,13 +7,16 @@ import { DSpaceObject } from '../core/shared/dspace-object.model'; import { pushInOut } from '../shared/animations/push'; import { HostWindowService } from '../shared/host-window.service'; import { PaginatedSearchOptions } from './paginated-search-options.model'; -import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchResult } from './search-result.model'; import { SearchService } from './search-service/search.service'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; -import { hasValue } from '../shared/empty.util'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { getSucceededRemoteData } from '../core/shared/operators'; +import { RouteService } from '../shared/services/route.service'; +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; + +export const SEARCH_ROUTE = '/search'; /** * This component renders a simple item page. @@ -26,11 +29,18 @@ import { getSucceededRemoteData } from '../core/shared/operators'; styleUrls: ['./search-page.component.scss'], templateUrl: './search-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [pushInOut] + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] }) /** * This component represents the whole search page + * It renders search results depending on the current search options */ export class SearchPageComponent implements OnInit { @@ -59,11 +69,34 @@ export class SearchPageComponent implements OnInit { */ sub: Subscription; - constructor(private service: SearchService, - private sidebarService: SearchSidebarService, - private windowService: HostWindowService, - private filterService: SearchFilterService, - private searchConfigService: SearchConfigurationService) { + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch = true; + + /** + * Whether or not the search bar should be visible + */ + @Input() + searchEnabled = true; + + /** + * The width of the sidebar (bootstrap columns) + */ + @Input() + sideBarWidth = 3; + + /** + * The currently applied filter (determines title of search) + */ + @Input() + fixedFilter$: Observable; + + constructor(protected service: SearchService, + protected sidebarService: SearchSidebarService, + protected windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + protected routeService: RouteService) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -75,7 +108,7 @@ export class SearchPageComponent implements OnInit { * If something changes, update the list of scopes for the dropdown */ ngOnInit(): void { - this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; + this.searchOptions$ = this.getSearchOptions(); this.sub = this.searchOptions$.pipe( switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))) .subscribe((results) => { @@ -84,6 +117,17 @@ export class SearchPageComponent implements OnInit { this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( switchMap((scopeId) => this.service.getScopes(scopeId)) ); + if (!isNotEmpty(this.fixedFilter$)) { + this.fixedFilter$ = this.routeService.getRouteParameterValue('filter'); + } + } + + /** + * Get the current paginated search options + * @returns {Observable} + */ + protected getSearchOptions(): Observable { + return this.searchConfigService.paginatedSearchOptions; } /** @@ -109,9 +153,12 @@ export class SearchPageComponent implements OnInit { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.service.getSearchLink(); } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index 0c8a4ee306..65558eae17 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -5,13 +5,9 @@ import { SharedModule } from '../shared/shared.module'; import { SearchPageRoutingModule } from './search-page-routing.module'; import { SearchPageComponent } from './search-page.component'; import { SearchResultsComponent } from './search-results/search-results.component'; -import { ItemSearchResultListElementComponent } from '../shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component'; -import { CollectionSearchResultListElementComponent } from '../shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component'; -import { CommunitySearchResultListElementComponent } from '../shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component'; import { ItemSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component'; import { CommunitySearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component' import { CollectionSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; -import { SearchService } from './search-service/search.service'; import { SearchSidebarComponent } from './search-sidebar/search-sidebar.component'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchSidebarEffects } from './search-sidebar/search-sidebar.effects'; @@ -21,6 +17,9 @@ import { SearchFiltersComponent } from './search-filters/search-filters.componen import { SearchFilterComponent } from './search-filters/search-filter/search-filter.component'; import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; +import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; +import { FilteredSearchPageGuard } from './filtered-search-page.guard'; import { SearchLabelsComponent } from './search-labels/search-labels.component'; import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component'; import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component'; @@ -28,11 +27,42 @@ import { SearchFacetFilterWrapperComponent } from './search-filters/search-filte import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component'; import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component'; import { SearchConfigurationService } from './search-service/search-configuration.service'; +import { SearchFacetOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component'; +import { SearchFacetSelectedOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component'; +import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; +import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component'; +import { SearchAuthorityFilterComponent } from './search-filters/search-filter/search-authority-filter/search-authority-filter.component'; const effects = [ SearchSidebarEffects ]; +const components = [ + SearchPageComponent, + SearchResultsComponent, + SearchSidebarComponent, + SearchSettingsComponent, + ItemSearchResultGridElementComponent, + CollectionSearchResultGridElementComponent, + CommunitySearchResultGridElementComponent, + SearchFiltersComponent, + SearchFilterComponent, + SearchFacetFilterComponent, + SearchLabelsComponent, + SearchFacetFilterComponent, + SearchFacetFilterWrapperComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent, + SearchSwitchConfigurationComponent, + SearchAuthorityFilterComponent, + FilteredSearchPageComponent +]; + @NgModule({ imports: [ SearchPageRoutingModule, @@ -41,39 +71,16 @@ const effects = [ EffectsModule.forFeature(effects), CoreModule.forRoot() ], - declarations: [ - SearchPageComponent, - SearchResultsComponent, - SearchSidebarComponent, - SearchSettingsComponent, - ItemSearchResultListElementComponent, - CollectionSearchResultListElementComponent, - CommunitySearchResultListElementComponent, - ItemSearchResultGridElementComponent, - CollectionSearchResultGridElementComponent, - CommunitySearchResultGridElementComponent, - CommunitySearchResultListElementComponent, - SearchFiltersComponent, - SearchFilterComponent, - SearchFacetFilterComponent, - SearchLabelsComponent, - SearchFacetFilterComponent, - SearchFacetFilterWrapperComponent, - SearchRangeFilterComponent, - SearchTextFilterComponent, - SearchHierarchyFilterComponent, - SearchBooleanFilterComponent, - ], + declarations: components, providers: [ - SearchService, SearchSidebarService, SearchFilterService, + SearchFixedFilterService, + FilteredSearchPageGuard, + SearchFilterService, SearchConfigurationService ], entryComponents: [ - ItemSearchResultListElementComponent, - CollectionSearchResultListElementComponent, - CommunitySearchResultListElementComponent, ItemSearchResultGridElementComponent, CollectionSearchResultGridElementComponent, CommunitySearchResultGridElementComponent, @@ -82,7 +89,12 @@ const effects = [ SearchTextFilterComponent, SearchHierarchyFilterComponent, SearchBooleanFilterComponent, - ] + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent, + SearchAuthorityFilterComponent + ], + exports: components }) /** diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts index 00b1c62a99..0354edbc6b 100644 --- a/src/app/+search-page/search-result.model.ts +++ b/src/app/+search-page/search-result.model.ts @@ -1,5 +1,5 @@ import { DSpaceObject } from '../core/shared/dspace-object.model'; -import { Metadatum } from '../core/shared/metadatum.model'; +import { MetadataMap } from '../core/shared/metadata.models'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; /** @@ -9,11 +9,11 @@ export class SearchResult implements ListableObject { /** * The DSpaceObject that was found */ - dspaceObject: T; + indexableObject: T; /** * The metadata that was used to find this item, hithighlighted */ - hitHighlights: Metadatum[]; + hitHighlights: MetadataMap; } diff --git a/src/app/+search-page/search-results/search-results.component.html b/src/app/+search-page/search-results/search-results.component.html index 4915b552c3..d7ecb0357e 100644 --- a/src/app/+search-page/search-results/search-results.component.html +++ b/src/app/+search-page/search-results/search-results.component.html @@ -1,4 +1,4 @@ -

{{ 'search.results.head' | translate }}

+

{{ getTitleKey() | translate }}

{ let comp: SearchResultsComponent; let fixture: ComponentFixture; + let heading: DebugElement; + let title: DebugElement; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -24,7 +26,9 @@ describe('SearchResultsComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SearchResultsComponent); - comp = fixture.componentInstance; // SearchResultsComponent test instance + comp = fixture.componentInstance; // SearchFormComponent test instance + heading = fixture.debugElement.query(By.css('heading')); + title = fixture.debugElement.query(By.css('h2')); }); it('should display results when results are not empty', () => { @@ -111,34 +115,38 @@ export const objects = [ id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', type: ResourceType.Community, - name: 'OR2017 - Demonstration', - metadata: [ - { - key: 'dc.description', - language: null, - value: '' - }, - { - key: 'dc.description.abstract', - language: null, - value: 'This is a test community to hold content for the OR2017 demostration' - }, - { - key: 'dc.description.tableofcontents', - language: null, - value: '' - }, - { - key: 'dc.rights', - language: null, - value: '' - }, - { - key: 'dc.title', - language: null, - value: 'OR2017 - Demonstration' - } - ] + metadata: { + 'dc.description': [ + { + language: null, + value: '' + } + ], + 'dc.description.abstract': [ + { + language: null, + value: 'This is a test community to hold content for the OR2017 demostration' + } + ], + 'dc.description.tableofcontents': [ + { + language: null, + value: '' + } + ], + 'dc.rights': [ + { + language: null, + value: '' + } + ], + 'dc.title': [ + { + language: null, + value: 'OR2017 - Demonstration' + } + ] + } }), Object.assign(new Community(), { @@ -161,34 +169,38 @@ export const objects = [ id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', type: ResourceType.Community, - name: 'Sample Community', - metadata: [ - { - key: 'dc.description', - language: null, - value: '

This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).

\r\n

DSpace Communities may contain one or more Sub-Communities or Collections (of Items).

\r\n

This particular Community has its own logo (the DuraSpace logo).

' - }, - { - key: 'dc.description.abstract', - language: null, - value: 'This is a sample top-level community' - }, - { - key: 'dc.description.tableofcontents', - language: null, - value: '

This is the news section for this Sample Community. System or Community Administrators (of this Community) can edit this News field.

' - }, - { - key: 'dc.rights', - language: null, - value: '

If this Community had special copyright text to display, it would be displayed here.

' - }, - { - key: 'dc.title', - language: null, - value: 'Sample Community' - } - ] + metadata: { + 'dc.description': [ + { + language: null, + value: '

This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).

\r\n

DSpace Communities may contain one or more Sub-Communities or Collections (of Items).

\r\n

This particular Community has its own logo (the DuraSpace logo).

' + } + ], + 'dc.description.abstract': [ + { + language: null, + value: 'This is a sample top-level community' + } + ], + 'dc.description.tableofcontents': [ + { + language: null, + value: '

This is the news section for this Sample Community. System or Community Administrators (of this Community) can edit this News field.

' + } + ], + 'dc.rights': [ + { + language: null, + value: '

If this Community had special copyright text to display, it would be displayed here.

' + } + ], + 'dc.title': [ + { + language: null, + value: 'Sample Community' + } + ] + } } ) ]; diff --git a/src/app/+search-page/search-results/search-results.component.ts b/src/app/+search-page/search-results/search-results.component.ts index ae0abfcd27..9656ba9574 100644 --- a/src/app/+search-page/search-results/search-results.component.ts +++ b/src/app/+search-page/search-results/search-results.component.ts @@ -2,11 +2,12 @@ import { Component, Input } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { SetViewMode } from '../../shared/view-mode'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; import { PaginatedList } from '../../core/data/paginated-list'; -import { ViewMode } from '../../core/shared/view-mode.model'; import { isNotEmpty } from '../../shared/empty.util'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-search-results', @@ -32,9 +33,38 @@ export class SearchResultsComponent { @Input() searchConfig: SearchOptions; /** - * The current view mode for the search results + * The current sorting configuration of the search */ - @Input() viewMode: ViewMode; + @Input() sortConfig: SortOptions; + + /** + * The current view-mode of the list + */ + @Input() viewMode: SetViewMode; + + /** + * An optional fixed filter to filter the result on one type + */ + @Input() fixedFilter: string; + + /** + * Whether or not to hide the header of the results + * Defaults to a visible header + */ + @Input() disableHeader = false; + + /** + * Get the i18n key for the title depending on the fixed filter + * Defaults to 'search.results.head' if there's no fixed filter found + * @returns {string} + */ + getTitleKey() { + if (isNotEmpty(this.fixedFilter)) { + return 'search.' + this.fixedFilter + '.results.head' + } else { + return 'search.results.head'; + } + } /** * Method to change the given string by surrounding it by quotes if not already present. diff --git a/src/app/+search-page/search-service/facet-value.model.ts b/src/app/+search-page/search-service/facet-value.model.ts index a597528d50..d2cc521356 100644 --- a/src/app/+search-page/search-service/facet-value.model.ts +++ b/src/app/+search-page/search-service/facet-value.model.ts @@ -1,4 +1,3 @@ - import { autoserialize, autoserializeAs } from 'cerialize'; /** @@ -6,7 +5,13 @@ import { autoserialize, autoserializeAs } from 'cerialize'; */ export class FacetValue { /** - * The display value of the facet value + * The display label of the facet value + */ + @autoserialize + label: string; + + /** + * The value of the facet value */ @autoserializeAs(String, 'label') value: string; diff --git a/src/app/+search-page/search-service/filter-type.model.ts b/src/app/+search-page/search-service/filter-type.model.ts index d9b9629347..d5a338de6d 100644 --- a/src/app/+search-page/search-service/filter-type.model.ts +++ b/src/app/+search-page/search-service/filter-type.model.ts @@ -2,6 +2,11 @@ * Enumeration containing all possible types for filters */ export enum FilterType { + /** + * Represents authority facets + */ + authority = 'authority', + /** * Represents simple text facets */ diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/+search-page/search-service/search-configuration.service.spec.ts index af8897c93b..79932805c1 100644 --- a/src/app/+search-page/search-service/search-configuration.service.spec.ts +++ b/src/app/+search-page/search-service/search-configuration.service.spec.ts @@ -17,23 +17,28 @@ describe('SearchConfigurationService', () => { const defaults = new PaginatedSearchOptions({ pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }), sort: new SortOptions('score', SortDirection.DESC), + configuration: 'default', query: '', scope: '' }); const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])]; - const spy = jasmine.createSpyObj('RouteService', { + const routeService = jasmine.createSpyObj('RouteService', { getQueryParameterValue: observableOf(value1), - getQueryParamsWithPrefix: observableOf(prefixFilter) + getQueryParamsWithPrefix: observableOf(prefixFilter), + getRouteParameterValue: observableOf('') + }); + + const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { + getQueryByFilterName: observableOf(''), }); const activatedRoute: any = new ActivatedRouteStub(); beforeEach(() => { - service = new SearchConfigurationService(spy, activatedRoute); + service = new SearchConfigurationService(routeService, fixedFilterService, activatedRoute); }); - describe('when the scope is called', () => { beforeEach(() => { service.getCurrentScope(''); @@ -43,6 +48,15 @@ describe('SearchConfigurationService', () => { }); }); + describe('when getCurrentConfiguration is called', () => { + beforeEach(() => { + service.getCurrentConfiguration(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'configuration\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('configuration'); + }); + }); + describe('when getCurrentQuery is called', () => { beforeEach(() => { service.getCurrentQuery(''); @@ -94,6 +108,7 @@ describe('SearchConfigurationService', () => { expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField'); }); }); + describe('when getCurrentPagination is called', () => { beforeEach(() => { service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any); @@ -105,11 +120,13 @@ describe('SearchConfigurationService', () => { expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize'); }); }); + describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => { beforeEach(() => { spyOn(service, 'getCurrentPagination').and.callThrough(); spyOn(service, 'getCurrentSort').and.callThrough(); spyOn(service, 'getCurrentScope').and.callThrough(); + spyOn(service, 'getCurrentConfiguration').and.callThrough(); spyOn(service, 'getCurrentQuery').and.callThrough(); spyOn(service, 'getCurrentDSOType').and.callThrough(); spyOn(service, 'getCurrentFilters').and.callThrough(); @@ -117,12 +134,13 @@ describe('SearchConfigurationService', () => { describe('when subscribeToSearchOptions is called', () => { beforeEach(() => { - service.subscribeToSearchOptions(defaults) + (service as any).subscribeToSearchOptions(defaults) }); it('should call all getters it needs, but not call any others', () => { expect(service.getCurrentPagination).not.toHaveBeenCalled(); expect(service.getCurrentSort).not.toHaveBeenCalled(); expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); expect(service.getCurrentQuery).toHaveBeenCalled(); expect(service.getCurrentDSOType).toHaveBeenCalled(); expect(service.getCurrentFilters).toHaveBeenCalled(); @@ -131,16 +149,42 @@ describe('SearchConfigurationService', () => { describe('when subscribeToPaginatedSearchOptions is called', () => { beforeEach(() => { - service.subscribeToPaginatedSearchOptions(defaults); + (service as any).subscribeToPaginatedSearchOptions(defaults); }); it('should call all getters it needs', () => { expect(service.getCurrentPagination).toHaveBeenCalled(); expect(service.getCurrentSort).toHaveBeenCalled(); expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); expect(service.getCurrentQuery).toHaveBeenCalled(); expect(service.getCurrentDSOType).toHaveBeenCalled(); expect(service.getCurrentFilters).toHaveBeenCalled(); }); }); }); + + describe('when getCurrentFixedFilter is called', () => { + beforeEach(() => { + service.getCurrentFixedFilter(); + }); + it('should call getRouteParameterValue on the routeService with parameter name \'filter\'', () => { + expect((service as any).routeService.getRouteParameterValue).toHaveBeenCalledWith('filter'); + }); + }); + + describe('when updateFixedFilter is called', () => { + const filter = 'filter'; + + beforeEach(() => { + service.updateFixedFilter(filter); + }); + + it('should update the paginated search options with the correct fixed filter', () => { + expect(service.paginatedSearchOptions.getValue().fixedFilter).toEqual(filter); + }); + + it('should update the search options with the correct fixed filter', () => { + expect(service.searchOptions.getValue().fixedFilter).toEqual(filter); + }); + }); }); diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts index 292f26724d..39acd19ccd 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -1,3 +1,6 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; + import { BehaviorSubject, combineLatest as observableCombineLatest, @@ -6,19 +9,18 @@ import { of as observableOf, Subscription } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { filter, flatMap, map } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SearchOptions } from '../search-options.model'; -import { ActivatedRoute, Params } from '@angular/router'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { Injectable, OnDestroy } from '@angular/core'; import { RouteService } from '../../shared/services/route.service'; -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from '../../core/data/remote-data'; import { getSucceededRemoteData } from '../../core/shared/operators'; import { SearchFilter } from '../search-filter.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; +import { SearchFixedFilterService } from '../search-filters/search-filter/search-fixed-filter.service'; /** * Service that performs all actions that have to do with the current search configuration @@ -28,7 +30,7 @@ export class SearchConfigurationService implements OnDestroy { /** * Default pagination settings */ - private defaultPagination = Object.assign(new PaginationComponentOptions(), { + protected defaultPagination = Object.assign(new PaginationComponentOptions(), { id: 'search-page-configuration', pageSize: 10, currentPage: 1 @@ -37,22 +39,27 @@ export class SearchConfigurationService implements OnDestroy { /** * Default sort settings */ - private defaultSort = new SortOptions('score', SortDirection.DESC); + protected defaultSort = new SortOptions('score', SortDirection.DESC); + + /** + * Default configuration parameter setting + */ + protected defaultConfiguration = 'default'; /** * Default scope setting */ - private defaultScope = ''; + protected defaultScope = ''; /** * Default query setting */ - private defaultQuery = ''; + protected defaultQuery = ''; /** * Emits the current default values */ - private _defaults: Observable>; + protected _defaults: Observable>; /** * Emits the current search options @@ -67,28 +74,48 @@ export class SearchConfigurationService implements OnDestroy { /** * List of subscriptions to unsubscribe from on destroy */ - private subs: Subscription[] = new Array(); + protected subs: Subscription[] = new Array(); /** * Initialize the search options * @param {RouteService} routeService + * @param {SearchFixedFilterService} fixedFilterService * @param {ActivatedRoute} route */ - constructor(private routeService: RouteService, - private route: ActivatedRoute) { + constructor(protected routeService: RouteService, + protected fixedFilterService: SearchFixedFilterService, + protected route: ActivatedRoute) { + + this.initDefaults(); + } + + /** + * Initialize the search options + */ + protected initDefaults() { this.defaults .pipe(getSucceededRemoteData()) .subscribe((defRD) => { const defs = defRD.payload; - this.paginatedSearchOptions = new BehaviorSubject(defs); - this.searchOptions = new BehaviorSubject(defs); + this.paginatedSearchOptions = new BehaviorSubject(defs); + this.searchOptions = new BehaviorSubject(defs); this.subs.push(this.subscribeToSearchOptions(defs)); this.subs.push(this.subscribeToPaginatedSearchOptions(defs)); + } ) } + /** + * @returns {Observable} Emits the current configuration string + */ + getCurrentConfiguration(defaultConfiguration: string) { + return this.routeService.getQueryParameterValue('configuration').pipe(map((configuration) => { + return configuration || defaultConfiguration; + })); + } + /** * @returns {Observable} Emits the current scope's identifier */ @@ -112,7 +139,7 @@ export class SearchConfigurationService implements OnDestroy { */ getCurrentDSOType(): Observable { return this.routeService.getQueryParameterValue('dsoType').pipe( - filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()])), + filter((type) => isNotEmpty(type) && hasValue(DSpaceObjectType[type.toUpperCase()])), map((type) => DSpaceObjectType[type.toUpperCase()]),); } @@ -174,6 +201,15 @@ export class SearchConfigurationService implements OnDestroy { })); } + /** + * @returns {Observable} Emits the current fixed filter as a string + */ + getCurrentFixedFilter(): Observable { + return this.routeService.getRouteParameterValue('filter').pipe( + flatMap((f) => this.fixedFilterService.getQueryByFilterName(f)) + ); + } + /** * @returns {Observable} Emits the current active filters with their values as they are displayed in the frontend URL */ @@ -186,12 +222,14 @@ export class SearchConfigurationService implements OnDestroy { * @param {SearchOptions} defaults Default values for when no parameters are available * @returns {Subscription} The subscription to unsubscribe from */ - subscribeToSearchOptions(defaults: SearchOptions): Subscription { + private subscribeToSearchOptions(defaults: SearchOptions): Subscription { return observableMerge( + this.getConfigurationPart(defaults.configuration), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), this.getDSOTypePart(), - this.getFiltersPart() + this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: SearchOptions = this.searchOptions.getValue(); const updatedValue: SearchOptions = Object.assign(currentValue, update); @@ -204,14 +242,16 @@ export class SearchConfigurationService implements OnDestroy { * @param {PaginatedSearchOptions} defaults Default values for when no parameters are available * @returns {Subscription} The subscription to unsubscribe from */ - subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription { + private subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription { return observableMerge( this.getPaginationPart(defaults.pagination), this.getSortPart(defaults.sort), + this.getConfigurationPart(defaults.configuration), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), this.getDSOTypePart(), - this.getFiltersPart() + this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update); @@ -226,6 +266,7 @@ export class SearchConfigurationService implements OnDestroy { if (hasNoValue(this._defaults)) { const options = new PaginatedSearchOptions({ pagination: this.defaultPagination, + configuration: this.defaultConfiguration, sort: this.defaultSort, scope: this.defaultScope, query: this.defaultQuery @@ -242,6 +283,16 @@ export class SearchConfigurationService implements OnDestroy { this.subs.forEach((sub) => { sub.unsubscribe(); }); + this.subs = []; + } + + /** + * @returns {Observable} Emits the current configuration settings as a partial SearchOptions object + */ + private getConfigurationPart(defaultConfiguration: string): Observable { + return this.getCurrentConfiguration(defaultConfiguration).pipe(map((configuration) => { + return { configuration } + })); } /** @@ -297,4 +348,30 @@ export class SearchConfigurationService implements OnDestroy { return { filters } })); } + + /** + * @returns {Observable} Emits the current fixed filter as a partial SearchOptions object + */ + private getFixedFilterPart(): Observable { + return this.getCurrentFixedFilter().pipe( + isNotEmptyOperator(), + map((fixedFilter) => { + return { fixedFilter } + }) + ); + } + + /** + * Update the fixed filter in paginated and non-paginated search options with a given value + * @param {string} fixedFilter + */ + public updateFixedFilter(fixedFilter: string) { + const currentPaginatedValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); + const updatedPaginatedValue: PaginatedSearchOptions = Object.assign(currentPaginatedValue, { fixedFilter: fixedFilter }); + this.paginatedSearchOptions.next(updatedPaginatedValue); + + const currentValue: SearchOptions = this.searchOptions.getValue(); + const updatedValue: SearchOptions = Object.assign(currentValue, { fixedFilter: fixedFilter }); + this.searchOptions.next(updatedValue); + } } diff --git a/src/app/+search-page/search-service/search-query-response.model.ts b/src/app/+search-page/search-service/search-query-response.model.ts index ac1d8b7df3..bca6e644fc 100644 --- a/src/app/+search-page/search-service/search-query-response.model.ts +++ b/src/app/+search-page/search-service/search-query-response.model.ts @@ -34,7 +34,7 @@ export class SearchQueryResponse { * The sort parameters used in the search request */ @autoserialize - configurationName: string; + configuration: string; /** * The sort parameters used in the search request diff --git a/src/app/+search-page/search-service/search-result-element-decorator.ts b/src/app/+search-page/search-service/search-result-element-decorator.ts index 348cf7f592..59446480a3 100644 --- a/src/app/+search-page/search-service/search-result-element-decorator.ts +++ b/src/app/+search-page/search-service/search-result-element-decorator.ts @@ -1,5 +1,6 @@ import { GenericConstructor } from '../../core/shared/generic-constructor'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { isNull } from '../../shared/empty.util'; /** * Contains the mapping between a search result component and a DSpaceObject @@ -11,12 +12,19 @@ const searchResultMap = new Map(); * @param {GenericConstructor} domainConstructor The constructor of the DSpaceObject * @returns Decorator function that performs the actual mapping on initialization of the component */ -export function searchResultFor(domainConstructor: GenericConstructor) { +export function searchResultFor(domainConstructor: GenericConstructor, configuration: string = null) { return function decorator(searchResult: any) { if (!searchResult) { return; } - searchResultMap.set(domainConstructor, searchResult); + if (isNull(configuration)) { + searchResultMap.set(domainConstructor, searchResult); + } else { + if (!searchResultMap.get(configuration)) { + searchResultMap.set(configuration, new Map()); + } + searchResultMap.get(configuration).set(domainConstructor, searchResult); + } }; } @@ -25,6 +33,10 @@ export function searchResultFor(domainConstructor: GenericConstructor} domainConstructor The DSpaceObject's constructor for which the search result component is requested * @returns The component's constructor that matches the given DSpaceObject */ -export function getSearchResultFor(domainConstructor: GenericConstructor) { - return searchResultMap.get(domainConstructor); +export function getSearchResultFor(domainConstructor: GenericConstructor, configuration: string = null) { + if (isNull(configuration) || configuration === 'default') { + return searchResultMap.get(domainConstructor); + } else { + return searchResultMap.get(configuration).get(domainConstructor); + } } diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index 4af0ffcb2e..9ec5bc35f2 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -5,28 +5,29 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; import { SearchService } from './search.service'; +import { ItemDataService } from './../../core/data/item-data.service'; +import { SetViewMode } from '../../shared/view-mode'; +import { GLOBAL_CONFIG } from '../../../config'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; -import { ActivatedRoute, Router, UrlTree } from '@angular/router'; +import { Router, UrlTree } from '@angular/router'; import { RequestService } from '../../core/data/request.service'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { RouterStub } from '../../shared/testing/router-stub'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { Observable, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { RemoteData } from '../../core/data/remote-data'; import { RequestEntry } from '../../core/data/request.reducer'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { - FacetConfigSuccessResponse, - SearchSuccessResponse -} from '../../core/cache/response.models'; +import { FacetConfigSuccessResponse, SearchSuccessResponse } from '../../core/cache/response.models'; import { SearchQueryResponse } from './search-query-response.model'; import { SearchFilterConfig } from './search-filter-config.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { of as observableOf } from 'rxjs'; import { map } from 'rxjs/operators'; +import { RouteService } from '../../shared/services/route.service'; +import { routeServiceStub } from '../../shared/testing/route-service-stub'; @Component({ template: '' }) class DummyComponent { @@ -50,7 +51,7 @@ describe('SearchService', () => { ], providers: [ { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: route }, + { provide: RouteService, useValue: routeServiceStub }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, @@ -71,7 +72,7 @@ describe('SearchService', () => { describe('', () => { let searchService: SearchService; const router = new RouterStub(); - const route = new ActivatedRouteStub(); + let routeService; const halService = { /* tslint:disable:no-empty */ @@ -107,7 +108,7 @@ describe('SearchService', () => { ], providers: [ { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: route }, + { provide: RouteService, useValue: routeServiceStub }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: HALEndpointService, useValue: halService }, @@ -117,6 +118,7 @@ describe('SearchService', () => { ], }); searchService = TestBed.get(SearchService); + routeService = TestBed.get(RouteService); const urlTree = Object.assign(new UrlTree(), { root: { children: { primary: 'search' } } }); router.parseUrl.and.returnValue(urlTree); }); @@ -124,7 +126,7 @@ describe('SearchService', () => { it('should call the navigate method on the Router with view mode list parameter as a parameter when setViewMode is called', () => { searchService.setViewMode(ViewMode.List); expect(router.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { view: ViewMode.List }, + queryParams: { view: ViewMode.List, page: 1 }, queryParamsHandling: 'merge' }); }); @@ -132,21 +134,26 @@ describe('SearchService', () => { it('should call the navigate method on the Router with view mode grid parameter as a parameter when setViewMode is called', () => { searchService.setViewMode(ViewMode.Grid); expect(router.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { view: ViewMode.Grid }, + queryParams: { view: ViewMode.Grid, page: 1 }, queryParamsHandling: 'merge' }); }); it('should return ViewMode.List when the viewMode is set to ViewMode.List in the ActivatedRoute', () => { let viewMode = ViewMode.Grid; - route.testParams = { view: ViewMode.List }; + spyOn(routeService, 'getQueryParamMap').and.returnValue(observableOf(new Map([ + [ 'view', ViewMode.List ], + ]))); + searchService.getViewMode().subscribe((mode) => viewMode = mode); expect(viewMode).toEqual(ViewMode.List); }); it('should return ViewMode.Grid when the viewMode is set to ViewMode.Grid in the ActivatedRoute', () => { let viewMode = ViewMode.List; - route.testParams = { view: ViewMode.Grid }; + spyOn(routeService, 'getQueryParamMap').and.returnValue(observableOf(new Map([ + [ 'view', ViewMode.Grid ], + ]))); searchService.getViewMode().subscribe((mode) => viewMode = mode); expect(viewMode).toEqual(ViewMode.Grid); }); @@ -155,7 +162,7 @@ describe('SearchService', () => { const endPoint = 'http://endpoint.com/test/test'; const searchOptions = new PaginatedSearchOptions({}); const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] }); - const response = new SearchSuccessResponse(queryResponse, '200'); + const response = new SearchSuccessResponse(queryResponse, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ @@ -180,7 +187,7 @@ describe('SearchService', () => { describe('when getConfig is called without a scope', () => { const endPoint = 'http://endpoint.com/test/config'; const filterConfig = [new SearchFilterConfig()]; - const response = new FacetConfigSuccessResponse(filterConfig, '200'); + const response = new FacetConfigSuccessResponse(filterConfig, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ @@ -207,7 +214,7 @@ describe('SearchService', () => { const scope = 'test'; const requestUrl = endPoint + '?scope=' + scope; const filterConfig = [new SearchFilterConfig()]; - const response = new FacetConfigSuccessResponse(filterConfig, '200'); + const response = new FacetConfigSuccessResponse(filterConfig, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 194adfa22f..aeeb4c0287 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,13 +1,7 @@ import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; -import { - ActivatedRoute, - NavigationExtras, - PRIMARY_OUTLET, - Router, - UrlSegmentGroup -} from '@angular/router'; -import { map, switchMap, take, tap } from 'rxjs/operators'; +import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; +import { first, map, switchMap, take } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { FacetConfigSuccessResponse, @@ -24,11 +18,12 @@ import { GenericConstructor } from '../../core/shared/generic-constructor'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { configureRequest, + filterSuccessfulResponses, getResponseFromEntry, getSucceededRemoteData } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { NormalizedSearchResult } from '../normalized-search-result.model'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; @@ -47,9 +42,7 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { ResourceType } from '../../core/shared/resource-type'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { Store } from '@ngrx/store'; -import { IndexName, IndexState } from '../../core/index/index.reducer'; -import { RemoveFromIndexBySubstringAction } from '../../core/index/index.actions'; +import { RouteService } from '../../shared/services/route.service'; /** * Service that performs all general actions that have to do with the search page @@ -66,13 +59,23 @@ export class SearchService implements OnDestroy { */ private facetLinkPathPrefix = 'discover/facets/'; + /** + * The ResponseParsingService constructor name + */ + private parser: GenericConstructor = SearchResponseParsingService; + + /** + * The RestRequest constructor name + */ + private request: GenericConstructor = GetRequest; + /** * Subscription to unsubscribe from */ private sub; constructor(private router: Router, - private route: ActivatedRoute, + private routeService: RouteService, protected requestService: RequestService, private rdb: RemoteDataBuildService, private halService: HALEndpointService, @@ -81,6 +84,20 @@ export class SearchService implements OnDestroy { ) { } + /** + * Method to set service options + * @param {GenericConstructor} parser The ResponseParsingService constructor name + * @param {boolean} request The RestRequest constructor name + */ + setServiceOptions(parser: GenericConstructor, request: GenericConstructor) { + if (parser) { + this.parser = parser; + } + if (request) { + this.request = request; + } + } + /** * Method to retrieve a paginated list of search results from the server * @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search @@ -92,14 +109,17 @@ export class SearchService implements OnDestroy { if (hasValue(searchOptions)) { url = (searchOptions as PaginatedSearchOptions).toRestUrl(url); } - const request = new GetRequest(this.requestService.generateRequestId(), url); + const request = new this.request(this.requestService.generateRequestId(), url); + + const getResponseParserFn: () => GenericConstructor = () => { + return this.parser; + }; + return Object.assign(request, { - getResponseParser(): GenericConstructor { - return SearchResponseParsingService; - } + getResponseParser: getResponseParserFn }); }), - configureRequest(this.requestService) + configureRequest(this.requestService), ); const requestEntryObs = requestObs.pipe( switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) @@ -107,7 +127,7 @@ export class SearchService implements OnDestroy { // get search results from response cache const sqrObs: Observable = requestEntryObs.pipe( - getResponseFromEntry(), + filterSuccessfulResponses(), map((response: SearchSuccessResponse) => response.results) ); @@ -115,8 +135,10 @@ export class SearchService implements OnDestroy { // Turn list of observable remote data DSO's into observable remote data object with list of DSO const dsoObs: Observable> = sqrObs.pipe( map((sqr: SearchQueryResponse) => { - return sqr.objects.map((nsr: NormalizedSearchResult) => { - return this.rdb.buildSingle(nsr.dspaceObject); + return sqr.objects + .filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject)) + .map((nsr: NormalizedSearchResult) => { + return this.rdb.buildSingle(nsr.indexableObject); }) }), switchMap((input: Array>>) => this.rdb.aggregate(input)), @@ -129,9 +151,9 @@ export class SearchService implements OnDestroy { let co = DSpaceObject; if (dsos.payload[index]) { const constructor: GenericConstructor = dsos.payload[index].constructor as GenericConstructor; - co = getSearchResultFor(constructor); + co = getSearchResultFor(constructor, searchOptions.configuration); return Object.assign(new co(), object, { - dspaceObject: dsos.payload[index] + indexableObject: dsos.payload[index] }); } else { return undefined; @@ -157,9 +179,10 @@ export class SearchService implements OnDestroy { /** * Request the filter configuration for a given scope or the whole repository * @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded + * @param {string} configurationName the name of the configuration * @returns {Observable>} The found filter configuration */ - getConfig(scope?: string): Observable> { + getConfig(scope?: string, configurationName?: string): Observable> { const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe( map((url: string) => { const args: string[] = []; @@ -168,11 +191,15 @@ export class SearchService implements OnDestroy { args.push(`scope=${scope}`); } + if (isNotEmpty(configurationName)) { + args.push(`configuration=${configurationName}`); + } + if (isNotEmpty(args)) { url = new URLCombiner(url, `?${args.join('&')}`).toString(); } - const request = new GetRequest(this.requestService.generateRequestId(), url); + const request = new this.request(this.requestService.generateRequestId(), url); return Object.assign(request, { getResponseParser(): GenericConstructor { return FacetConfigResponseParsingService; @@ -215,14 +242,15 @@ export class SearchService implements OnDestroy { url = searchOptions.toRestUrl(url, args); } - const request = new GetRequest(this.requestService.generateRequestId(), url); + const request = new this.request(this.requestService.generateRequestId(), url); return Object.assign(request, { getResponseParser(): GenericConstructor { return FacetValueResponseParsingService; } }); }), - configureRequest(this.requestService) + configureRequest(this.requestService), + first() ); const requestEntryObs = requestObs.pipe( @@ -291,9 +319,9 @@ export class SearchService implements OnDestroy { * @returns {Observable} The current view mode */ getViewMode(): Observable { - return this.route.queryParams.pipe(map((params) => { - if (isNotEmpty(params.view) && hasValue(params.view)) { - return params.view; + return this.routeService.getQueryParamMap().pipe(map((params) => { + if (isNotEmpty(params.get('view')) && hasValue(params.get('view'))) { + return params.get('view'); } else { return ViewMode.List; } @@ -304,22 +332,29 @@ export class SearchService implements OnDestroy { * Changes the current view mode in the current URL * @param {ViewMode} viewMode Mode to switch to */ - setViewMode(viewMode: ViewMode) { - const navigationExtras: NavigationExtras = { - queryParams: { view: viewMode }, - queryParamsHandling: 'merge' - }; + setViewMode(viewMode: ViewMode, searchLinkParts?: string[]) { + this.routeService.getQueryParameterValue('pageSize').pipe(first()) + .subscribe((pageSize) => { + let queryParams = { view: viewMode, page: 1 }; + if (viewMode === ViewMode.Detail) { + queryParams = Object.assign(queryParams, {pageSize: '1'}); + } else if (pageSize === '1') { + queryParams = Object.assign(queryParams, {pageSize: '10'}); + } + const navigationExtras: NavigationExtras = { + queryParams: queryParams, + queryParamsHandling: 'merge' + }; - this.router.navigate([this.getSearchLink()], navigationExtras); + this.router.navigate(hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], navigationExtras); + }) } /** * @returns {string} The base path to the search page */ getSearchLink(): string { - const urlTree = this.router.parseUrl(this.router.url); - const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET]; - return '/' + g.toString(); + return '/search'; } /** diff --git a/src/app/+search-page/search-settings/search-settings.component.spec.ts b/src/app/+search-page/search-settings/search-settings.component.spec.ts index b1585c4347..b9b5c5a5eb 100644 --- a/src/app/+search-page/search-settings/search-settings.component.spec.ts +++ b/src/app/+search-page/search-settings/search-settings.component.spec.ts @@ -14,8 +14,8 @@ import { By } from '@angular/platform-browser'; import { SearchFilterService } from '../search-filters/search-filter/search-filter.service'; import { hot } from 'jasmine-marbles'; import { VarDirective } from '../../shared/utils/var.directive'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; import { first } from 'rxjs/operators'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; describe('SearchSettingsComponent', () => { @@ -73,7 +73,7 @@ describe('SearchSettingsComponent', () => { useValue: {} }, { - provide: SearchConfigurationService, + provide: SEARCH_CONFIG_SERVICE, useValue: { paginatedSearchOptions: hot('a', { a: paginatedSearchOptions diff --git a/src/app/+search-page/search-settings/search-settings.component.ts b/src/app/+search-page/search-settings/search-settings.component.ts index 7fc5645fcc..aac861c64f 100644 --- a/src/app/+search-page/search-settings/search-settings.component.ts +++ b/src/app/+search-page/search-settings/search-settings.component.ts @@ -1,10 +1,11 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { Observable } from 'rxjs'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-settings', @@ -17,6 +18,11 @@ import { SearchConfigurationService } from '../search-service/search-configurati */ export class SearchSettingsComponent implements OnInit { + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * The configuration for the current paginated search results */ @@ -30,7 +36,7 @@ export class SearchSettingsComponent implements OnInit { constructor(private service: SearchService, private route: ActivatedRoute, private router: Router, - private searchConfigurationService: SearchConfigurationService) { + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigurationService: SearchConfigurationService) { } /** @@ -53,7 +59,7 @@ export class SearchSettingsComponent implements OnInit { }, queryParamsHandling: 'merge' }; - this.router.navigate([ '/search' ], navigationExtras); + this.router.navigate(this.getSearchLinkParts(), navigationExtras); } /** @@ -70,6 +76,26 @@ export class SearchSettingsComponent implements OnInit { }, queryParamsHandling: 'merge' }; - this.router.navigate([ '/search' ], navigationExtras); + this.router.navigate(this.getSearchLinkParts(), navigationExtras); + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.service.getSearchLink(); + } + + /** + * @returns {string[]} The base path to the search page, or the current page when inPlaceSearch is true, split in separate pieces + */ + public getSearchLinkParts(): string[] { + if (this.service) { + return []; + } + return this.getSearchLink().split('/'); } } diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.html b/src/app/+search-page/search-sidebar/search-sidebar.component.html index 5ff1e3c8fa..50877052ec 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.html +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.html @@ -8,10 +8,11 @@
- +
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.scss b/src/app/+search-page/search-sidebar/search-sidebar.component.scss index b5bd6dd30d..35ce5eebce 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.scss +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.scss @@ -8,8 +8,12 @@ ds-view-mode-switch { margin-bottom: $spacer; } - .sidebar-content > *:not(:last-child) { + .sidebar-content > *:not(:last-child):not(ds-search-switch-configuration) { margin-bottom: 4*$spacer; display: block; } + ds-search-switch-configuration { + margin-bottom: 2*$spacer; + display: block; + } } diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.ts b/src/app/+search-page/search-sidebar/search-sidebar.component.ts index 8b68cda793..9ee0a74942 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.ts @@ -1,5 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model'; + /** * This component renders a simple item page. * The route parameter 'id' is used to request the item it represents. @@ -17,13 +19,29 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; */ export class SearchSidebarComponent { + /** + * The list of available configuration options + */ + @Input() configurationList: SearchConfigurationOption[]; + /** * The total amount of results */ @Input() resultCount; + /** + * The list of available view mode options + */ + @Input() viewModeList; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Emits event when the user clicks a button to open or close the sidebar */ @Output() toggleSidebar = new EventEmitter(); + } diff --git a/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts new file mode 100644 index 0000000000..6f9a72da48 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts @@ -0,0 +1,15 @@ +/** + * Represents a search configuration select option + */ +export interface SearchConfigurationOption { + + /** + * The select option value + */ + value: string; + + /** + * The select option label + */ + label: string; +} diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html new file mode 100644 index 0000000000..8df37214d1 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html @@ -0,0 +1,13 @@ +
+
{{ 'search.switch-configuration.title' | translate}}
+ + + +
diff --git a/src/app/shared/mocks/mock-response-cache.service.ts b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss similarity index 100% rename from src/app/shared/mocks/mock-response-cache.service.ts rename to src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts new file mode 100644 index 0000000000..b3efc240e1 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts @@ -0,0 +1,109 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { SearchSwitchConfigurationComponent } from './search-switch-configuration.component'; +import { MYDSPACE_ROUTE, SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { NavigationExtras, Router } from '@angular/router'; +import { RouterStub } from '../../shared/testing/router-stub'; +import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; +import { SearchService } from '../search-service/search.service'; + +describe('SearchSwitchConfigurationComponent', () => { + + let comp: SearchSwitchConfigurationComponent; + let fixture: ComponentFixture; + let searchConfService: SearchConfigurationServiceStub; + let select: any; + + const searchServiceStub = jasmine.createSpyObj('SearchService', { + getSearchLink: jasmine.createSpy('getSearchLink') + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ SearchSwitchConfigurationComponent ], + providers: [ + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchServiceStub }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + ], + schemas: [ NO_ERRORS_SCHEMA ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchSwitchConfigurationComponent); + comp = fixture.componentInstance; + searchConfService = TestBed.get(SEARCH_CONFIG_SERVICE); + + spyOn(searchConfService, 'getCurrentConfiguration').and.returnValue(observableOf(MyDSpaceConfigurationValueType.Workspace)); + + comp.configurationList = [ + { + value: MyDSpaceConfigurationValueType.Workspace, + label: 'workspace' + }, + { + value: MyDSpaceConfigurationValueType.Workflow, + label: 'workflow' + }, + ]; + + // SearchSwitchConfigurationComponent test instance + fixture.detectChanges(); + + }); + + it('should init the current configuration name', () => { + expect(comp.selectedOption).toBe(MyDSpaceConfigurationValueType.Workspace); + }); + + it('should display select field properly', () => { + const selectField = fixture.debugElement.query(By.css('.form-control')); + expect(selectField).toBeDefined(); + + const childElements = selectField.children; + expect(childElements.length).toEqual(comp.configurationList.length); + }); + + it('should call onSelect method when selecting an option', () => { + fixture.whenStable().then(() => { + spyOn(comp, 'onSelect'); + select = fixture.debugElement.query(By.css('select')); + const selectEl = select.nativeElement; + selectEl.value = selectEl.options[1].value; // <-- select a new value + selectEl.dispatchEvent(new Event('change')); + fixture.detectChanges(); + expect(comp.onSelect).toHaveBeenCalled(); + }); + + }); + + it('should navigate to the route when selecting an option', () => { + (comp as any).searchService.getSearchLink.and.returnValue(MYDSPACE_ROUTE); + comp.selectedOption = MyDSpaceConfigurationValueType.Workflow; + const navigationExtras: NavigationExtras = { + queryParams: {configuration: MyDSpaceConfigurationValueType.Workflow}, + }; + + fixture.detectChanges(); + + comp.onSelect(); + + expect((comp as any).router.navigate).toHaveBeenCalledWith([MYDSPACE_ROUTE], navigationExtras); + }); +}); diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts new file mode 100644 index 0000000000..c34fe20303 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts @@ -0,0 +1,80 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { NavigationExtras, Router } from '@angular/router'; + +import { Subscription } from 'rxjs'; + +import { hasValue } from '../../shared/empty.util'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; +import { SearchConfigurationOption } from './search-configuration-option.model'; +import { SearchService } from '../search-service/search.service'; + +@Component({ + selector: 'ds-search-switch-configuration', + styleUrls: ['./search-switch-configuration.component.scss'], + templateUrl: './search-switch-configuration.component.html', +}) +/** + * Represents a select that allow to switch over available search configurations + */ +export class SearchSwitchConfigurationComponent implements OnDestroy, OnInit { + + /** + * The list of available configuration options + */ + @Input() configurationList: SearchConfigurationOption[] = []; + + /** + * The selected option + */ + public selectedOption: string; + + /** + * Subscription to unsubscribe from + */ + private sub: Subscription; + + constructor(private router: Router, + private searchService: SearchService, + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { + } + + /** + * Init current configuration + */ + ngOnInit() { + this.searchConfigService.getCurrentConfiguration('default') + .subscribe((currentConfiguration) => this.selectedOption = currentConfiguration); + } + + /** + * Init current configuration + */ + onSelect() { + const navigationExtras: NavigationExtras = { + queryParams: {configuration: this.selectedOption}, + }; + + this.router.navigate([this.searchService.getSearchLink()], navigationExtras); + } + + /** + * Define the select 'compareWith' method to tell Angular how to compare the values + * + * @param item1 + * @param item2 + */ + compare(item1: MyDSpaceConfigurationValueType, item2: MyDSpaceConfigurationValueType) { + return item1 === item2; + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy() { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+submit-page/submit-page-routing.module.ts b/src/app/+submit-page/submit-page-routing.module.ts new file mode 100644 index 0000000000..7a123bfc31 --- /dev/null +++ b/src/app/+submit-page/submit-page-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionSubmitComponent } from '../submission/submit/submission-submit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [AuthenticatedGuard], + path: '', + pathMatch: 'full', + component: SubmissionSubmitComponent, + data: { title: 'submission.submit.title' } + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the submit page path. + */ +export class SubmitPageRoutingModule { } diff --git a/src/app/+submit-page/submit-page.module.ts b/src/app/+submit-page/submit-page.module.ts new file mode 100644 index 0000000000..e43d9d36aa --- /dev/null +++ b/src/app/+submit-page/submit-page.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { SubmitPageRoutingModule } from './submit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + SubmitPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], +}) +/** + * This module handles all modules that need to access the submit page. + */ +export class SubmitPageModule { + +} diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts new file mode 100644 index 0000000000..aa182eb291 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + canActivate: [AuthenticatedGuard], + path: ':id/edit', + component: SubmissionEditComponent, + data: { title: 'submission.edit.title' } + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the workflowitems edit page path. + */ +export class WorkflowitemsEditPageRoutingModule { } diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts new file mode 100644 index 0000000000..fbb53d8dcc --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { WorkflowitemsEditPageRoutingModule } from './workflowitems-edit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + WorkflowitemsEditPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], + declarations: [] +}) +/** + * This module handles all modules that need to access the workflowitems edit page. + */ +export class WorkflowitemsEditPageModule { + +} diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts new file mode 100644 index 0000000000..d10c53e138 --- /dev/null +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + canActivate: [AuthenticatedGuard], + path: ':id/edit', + component: SubmissionEditComponent, + data: { title: 'submission.edit.title' } + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the workspaceitems edit page path + */ +export class WorkspaceitemsEditPageRoutingModule { } diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts new file mode 100644 index 0000000000..65a40f3f7c --- /dev/null +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { WorkspaceitemsEditPageRoutingModule } from './workspaceitems-edit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + WorkspaceitemsEditPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], + declarations: [] +}) +/** + * This module handles all modules that need to access the workspaceitems edit page. + */ +export class WorkspaceitemsEditPageModule { + +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index e7ea10598d..cb80d0165e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -2,24 +2,37 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; +import { AuthenticatedGuard } from './core/auth/authenticated.guard'; const ITEM_MODULE_PATH = 'items'; export function getItemModulePath() { return `/${ITEM_MODULE_PATH}`; } +const COLLECTION_MODULE_PATH = 'collections'; +export function getCollectionModulePath() { + return `/${COLLECTION_MODULE_PATH}`; +} +const COMMUNITY_MODULE_PATH = 'communities'; +export function getCommunityModulePath() { + return `/${COMMUNITY_MODULE_PATH}`; +} @NgModule({ imports: [ RouterModule.forRoot([ { path: '', redirectTo: '/home', pathMatch: 'full' }, { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' }, - { path: 'communities', loadChildren: './+community-page/community-page.module#CommunityPageModule' }, - { path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, + { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, + { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, + { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, - { path: 'admin', loadChildren: './+admin/admin.module#AdminModule' }, + { path: 'admin', loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, + { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, + { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, + { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowitemsEditPageModule' }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], diff --git a/src/app/app.component.scss b/src/app/app.component.scss index c90d35678d..fa7e7a873a 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -30,11 +30,16 @@ body { .main-content { z-index: $main-z-index; - flex: 1 0 auto; + flex: 1 1 100%; margin-top: $content-spacing; margin-bottom: $content-spacing; } +.alert.hide { + padding: 0; + margin: 0; +} + ds-header-navbar-wrapper { z-index: $nav-z-index; } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index e079400e85..bd2d832c67 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -34,13 +34,16 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from './shared/mocks/mock-angulartics.service'; import { AuthServiceMock } from './shared/mocks/mock-auth.service'; import { AuthService } from './core/auth/auth.service'; -import { Router } from '@angular/router'; import { MenuService } from './shared/menu/menu.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { CSSVariableServiceStub } from './shared/testing/css-variable-service-stub'; import { MenuServiceStub } from './shared/testing/menu-service-stub'; import { HostWindowService } from './shared/host-window.service'; import { HostWindowServiceStub } from './shared/testing/host-window-service-stub'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouteService } from './shared/services/route.service'; +import { MockActivatedRoute } from './shared/mocks/mock-active-router'; +import { MockRouter } from './shared/mocks/mock-router'; let comp: AppComponent; let fixture: ComponentFixture; @@ -70,11 +73,13 @@ describe('App component', () => { { provide: MetadataService, useValue: new MockMetadataService() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, { provide: AuthService, useValue: new AuthServiceMock() }, - { provide: Router, useValue: {} }, + { provide: Router, useValue: new MockRouter() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - AppComponent + AppComponent, + RouteService ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 30a8f01251..da01b1297a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -23,6 +23,7 @@ import { NativeWindowRef, NativeWindowService } from './shared/services/window.s import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { RouteService } from './shared/services/route.service'; import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; @@ -56,17 +57,28 @@ export class AppComponent implements OnInit, AfterViewInit { private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, private authService: AuthService, private router: Router, + private routeService: RouteService, private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService ) { - // this language will be used as a fallback when a translation isn't found in the current language - translate.setDefaultLang('en'); - // the lang to use, if the lang isn't available, it will use the current loader to get them - translate.use('en'); + // Load all the languages that are defined as active from the config file + translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); + + // Load the default language from the config file + translate.setDefaultLang(config.defaultLanguage); + + // Attempt to get the browser language from the user + if (translate.getLangs().includes(translate.getBrowserLang())) { + translate.use(translate.getBrowserLang()); + } else { + translate.use(config.defaultLanguage); + } metadata.listenForRouteChange(); + routeService.saveRouting(); + if (config.debug) { console.info(config); } @@ -75,7 +87,6 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { - const env: string = this.config.production ? 'Production' : 'Development'; const color: string = this.config.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 8c4126f8ed..f9d6e50dcc 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,7 @@ import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-s import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { NotificationComponent } from './shared/notifications/notification/notification.component'; import { SharedModule } from './shared/shared.module'; +import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { HeaderNavbarWrapperComponent } from './header-nav-wrapper/header-navbar-wrapper.component'; import { AdminSidebarComponent } from './+admin/admin-sidebar/admin-sidebar.component'; import { AdminSidebarSectionComponent } from './+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; @@ -57,6 +58,7 @@ const IMPORTS = [ HttpClientModule, AppRoutingModule, CoreModule.forRoot(), + ScrollToModule.forRoot(), NgbModule.forRoot(), TranslateModule.forRoot(), EffectsModule.forRoot(appEffects), diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index cecc1ab862..5b56da8684 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -15,15 +15,22 @@ import { NotificationsState } from './shared/notifications/notifications.reducers'; import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; +import { + metadataRegistryReducer, + MetadataRegistryState +} from './+admin/admin-registries/metadata-registry/metadata-registry.reducers'; import { hasValue } from './shared/empty.util'; import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; +import { historyReducer, HistoryState } from './shared/history/history.reducer'; import { ObjectSelectionListState, objectSelectionReducer } from './shared/object-select/object-select.reducer'; export interface AppState { router: fromRouter.RouterReducerState; + history: HistoryState; hostWindow: HostWindowState; forms: FormState; + metadataRegistry: MetadataRegistryState; notifications: NotificationsState; searchSidebar: SearchSidebarState; searchFilter: SearchFiltersState; @@ -35,8 +42,10 @@ export interface AppState { export const appReducers: ActionReducerMap = { router: fromRouter.routerReducer, + history: historyReducer, hostWindow: hostWindowReducer, forms: formReducer, + metadataRegistry: metadataRegistryReducer, notifications: notificationsReducer, searchSidebar: sidebarReducer, searchFilter: filterReducer, diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts index b6df1fac34..02458f4e3e 100644 --- a/src/app/core/auth/auth-object-factory.ts +++ b/src/app/core/auth/auth-object-factory.ts @@ -3,15 +3,20 @@ import { GenericConstructor } from '../shared/generic-constructor'; import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { EPerson } from '../eperson/models/eperson.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { NormalizedGroup } from '../eperson/models/normalized-group.model'; export class AuthObjectFactory { - public static getConstructor(type): GenericConstructor { + public static getConstructor(type): GenericConstructor> { switch (type) { case AuthType.EPerson: { return NormalizedEPerson } + case AuthType.Group: { + return NormalizedGroup + } + case AuthType.Status: { return NormalizedAuthStatus } diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index f957d807c1..cbabe5c3fd 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -6,10 +6,9 @@ import { RequestService } from '../data/request.service'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; -import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; +import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models'; import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RequestEntry } from '../data/request.reducer'; import { getResponseFromEntry } from '../shared/operators'; @Injectable() @@ -25,8 +24,6 @@ export class AuthRequestService { protected fetchRequest(request: RestRequest): Observable { return this.requestService.getByUUID(request.uuid).pipe( getResponseFromEntry(), - // TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed - // tap(() => this.responseCache.remove(request.href)), mergeMap((response) => { if (response.isSuccessful && isNotEmpty(response)) { return observableOf((response as AuthStatusResponse).response); @@ -58,8 +55,8 @@ export class AuthRequestService { map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), distinctUntilChanged(), map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options)), - tap((request: PostRequest) => this.requestService.configure(request, true)), - mergeMap((request: PostRequest) => this.fetchRequest(request)), + tap((request: GetRequest) => this.requestService.configure(request, true)), + mergeMap((request: GetRequest) => this.fetchRequest(request)), distinctUntilChanged()); } } diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts index a4131db489..0b2c32fc04 100644 --- a/src/app/core/auth/auth-response-parsing.service.spec.ts +++ b/src/app/core/auth/auth-response-parsing.service.spec.ts @@ -1,20 +1,36 @@ -import { AuthStatusResponse } from '../cache/response.models'; +import { async, TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; + +import { GlobalConfig } from '../../../config/global-config.interface'; +import { AuthStatusResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthResponseParsingService } from './auth-response-parsing.service'; import { AuthGetRequest, AuthPostRequest } from '../data/request.models'; import { MockStore } from '../../shared/testing/mock-store'; -import { ObjectCacheState } from '../cache/object-cache.reducer'; describe('AuthResponseParsingService', () => { let service: AuthResponseParsingService; - const EnvConfig = { cache: { msToLive: 1000 } } as any; - const store = new MockStore({}); - const objectCacheService = new ObjectCacheService(store as any); + const EnvConfig: GlobalConfig = { cache: { msToLive: 1000 } } as any; + let store: any; + let objectCacheService: ObjectCacheService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], + providers: [ + { provide: Store, useClass: MockStore } + ] + }).compileComponents(); + })); beforeEach(() => { + store = TestBed.get(Store); + objectCacheService = new ObjectCacheService(store as any); service = new AuthResponseParsingService(EnvConfig, objectCacheService); }); @@ -38,12 +54,14 @@ describe('AuthResponseParsingService', () => { expires: 1526318322000 }, } as AuthStatus, - statusCode: '200' + statusCode: 200, + statusText: '200' }; const validResponse1 = { payload: {}, - statusCode: '404' + statusCode: 404, + statusText: '404' }; const validResponse2 = { @@ -60,23 +78,26 @@ describe('AuthResponseParsingService', () => { handle: null, id: '4dc70ab5-cd73-492f-b007-3179d2d9296b', lastActive: '2018-05-14T17:03:31.277+0000', - metadata: [ - { - key: 'eperson.firstname', - language: null, - value: 'User' - }, - { - key: 'eperson.lastname', - language: null, - value: 'Test' - }, - { - key: 'eperson.language', - language: null, - value: 'en' - } - ], + metadata: { + 'eperson.firstname': [ + { + language: null, + value: 'User' + } + ], + 'eperson.lastname': [ + { + language: null, + value: 'Test' + } + ], + 'eperson.language': [ + { + language: null, + value: 'en' + } + ] + }, name: 'User Test', netid: 'myself@testshib.org', requireCertificate: false, @@ -99,7 +120,9 @@ describe('AuthResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: '200' + }; it('should return a AuthStatusResponse if data contains a valid AuthStatus object as payload', () => { diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 61559991ec..c736c3b22b 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -13,6 +13,7 @@ import { RestRequest } from '../data/request.models'; import { AuthType } from './auth-type'; import { AuthStatus } from './models/auth-status.model'; import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; @Injectable() export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -26,12 +27,11 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { - const response = this.process(data.payload, request.uuid); - return new AuthStatusResponse(response, data.statusCode); + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 200)) { + const response = this.process, AuthType>(data.payload, request.uuid); + return new AuthStatusResponse(response, data.statusCode, data.statusText); } else { - return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); + return new AuthStatusResponse(data.payload as NormalizedAuthStatus, data.statusCode, data.statusText); } } - } diff --git a/src/app/core/auth/auth-type.ts b/src/app/core/auth/auth-type.ts index 9a248da91f..f0460449ea 100644 --- a/src/app/core/auth/auth-type.ts +++ b/src/app/core/auth/auth-type.ts @@ -1,4 +1,5 @@ export enum AuthType { EPerson = 'eperson', - Status = 'status' + Status = 'status', + Group = 'group' } diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 0dc8abf860..8c2b4026e0 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -22,7 +22,7 @@ import { } from './auth.actions'; import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthService } from './auth.service'; -import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; +import { AuthState } from './auth.reducer'; import { EPersonMock } from '../../shared/testing/eperson-mock'; @@ -30,7 +30,7 @@ describe('AuthEffects', () => { let authEffects: AuthEffects; let actions: Observable; let authServiceStub; - const store: Store = jasmine.createSpyObj('store', { + const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index dd9e3fb5e7..da760b8faa 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -17,7 +17,7 @@ import { AppState } from '../../app.reducer'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { isNotEmpty, isUndefined } from '../../shared/empty.util'; +import { isNotEmpty, isUndefined, isNotNull } from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; @@ -142,7 +142,7 @@ export class AuthInterceptor implements HttpInterceptor { url: error.url }); return observableOf(authResponse); - } else if (this.isUnauthorized(error)) { + } else if (this.isUnauthorized(error) && isNotNull(token) && authService.isTokenExpired()) { // The access token provided is expired, revoked, malformed, or invalid for other reasons // Redirect to the login route this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired')); diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index d39c0a4590..e766a45e48 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -26,21 +26,24 @@ import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-da describe('AuthService test', () => { - const mockStore: Store = jasmine.createSpyObj('store', { - dispatch: {}, - pipe: observableOf(true) - }); + let mockStore: Store; let authService: AuthService; let authRequest; - const window = new NativeWindowRef(); - const routerStub = new RouterStub(); + let window; + let routerStub; let routeStub; let storage: CookieService; let token: AuthTokenInfo; let authenticatedState; - const rdbService = getMockRemoteDataBuildService(); + let rdbService; function init() { + mockStore = jasmine.createSpyObj('store', { + dispatch: {}, + pipe: observableOf(true) + }); + window = new NativeWindowRef(); + routerStub = new RouterStub(); token = new AuthTokenInfo('test_token'); token.expires = Date.now() + (1000 * 60 * 60); authenticatedState = { @@ -52,15 +55,14 @@ describe('AuthService test', () => { }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); - } + rdbService = getMockRemoteDataBuildService(); + spyOn(rdbService, 'build').and.returnValue({authenticated: true, eperson: observableOf({payload: {}})}); - beforeEach(() => { - init(); - }); + } describe('', () => { beforeEach(() => { - + init(); TestBed.configureTestingModule({ imports: [ CommonModule, @@ -73,7 +75,7 @@ describe('AuthService test', () => { { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, { provide: ActivatedRoute, useValue: routeStub }, - {provide: Store, useValue: mockStore}, + { provide: Store, useValue: mockStore }, { provide: RemoteDataBuildService, useValue: rdbService }, CookieService, AuthService @@ -137,7 +139,8 @@ describe('AuthService test', () => { { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, { provide: RemoteDataBuildService, useValue: rdbService }, - CookieService + CookieService, + AuthService ] }).compileComponents(); })); @@ -176,8 +179,8 @@ describe('AuthService test', () => { }); describe('', () => { - beforeEach(async(() => { + init(); TestBed.configureTestingModule({ imports: [ StoreModule.forRoot({ authReducer }) @@ -186,8 +189,10 @@ describe('AuthService test', () => { { provide: AuthRequestService, useValue: authRequest }, { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, + { provide: RemoteDataBuildService, useValue: rdbService }, ClientCookieService, - CookieService + CookieService, + AuthService ] }).compileComponents(); })); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 6a2b4afa6e..a01768e687 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,42 +1,27 @@ -import { Observable, of as observableOf } from 'rxjs'; -import { - distinctUntilChanged, - filter, - map, - startWith, - switchMap, - take, - withLatestFrom -} from 'rxjs/operators'; import { Inject, Injectable, Optional } from '@angular/core'; import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { RouterReducerState } from '@ngrx/router-store'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; import { EPerson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; - import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../../shared/services/cookie.service'; -import { - getAuthenticationToken, - getRedirectUrl, - isAuthenticated, - isTokenRefreshing -} from './selectors'; +import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -145,14 +130,10 @@ export class AuthService { headers = headers.append('Authorization', `Bearer ${token.accessToken}`); options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( + map((status) => this.rdbService.build(status)), switchMap((status: AuthStatus) => { - if (status.authenticated) { - // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... - // Review when https://jira.duraspace.org/browse/DS-4006 is fixed - // See https://github.com/DSpace/dspace-angular/issues/292 - const person$ = this.rdbService.buildSingle(status.eperson.toString()); - return person$.pipe(map((eperson) => eperson.payload)); + return status.eperson.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); } @@ -241,7 +222,6 @@ export class AuthService { throw(new Error('auth.errors.invalid-user')); } })) - } /** diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index b9091a86ad..af0622cd19 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -3,7 +3,7 @@ import {take} from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; +import {Observable, of} from 'rxjs'; import { select, Store } from '@ngrx/store'; // reducers diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index 37f8d76672..6e722a80c9 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -3,8 +3,9 @@ import { AuthTokenInfo } from './auth-token-info.model'; import { EPerson } from '../../eperson/models/eperson.model'; import { RemoteData } from '../../data/remote-data'; import { Observable } from 'rxjs'; +import { CacheableObject } from '../../cache/object-cache.reducer'; -export class AuthStatus { +export class AuthStatus implements CacheableObject { id: string; diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts index b8dd2aa23e..a13a996604 100644 --- a/src/app/core/auth/models/normalized-auth-status.model.ts +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -7,7 +7,7 @@ import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; @mapsTo(AuthStatus) @inheritSerialization(NormalizedObject) -export class NormalizedAuthStatus extends NormalizedObject { +export class NormalizedAuthStatus extends NormalizedObject { @autoserialize id: string; diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index fa637981ae..8c88e0fce5 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -8,6 +8,7 @@ import { createSelector } from '@ngrx/store'; */ import { AuthState } from './auth.reducer'; import { AppState } from '../../app.reducer'; +import { EPerson } from '../eperson/models/eperson.model'; /** * Returns the user state. @@ -35,11 +36,12 @@ const _isAuthenticatedLoaded = (state: AuthState) => state.loaded; /** * Return the users state + * NOTE: when state is REHYDRATED user object lose prototype so return always a new EPerson object * @function _getAuthenticatedUser * @param {State} state - * @returns {User} + * @returns {EPerson} */ -const _getAuthenticatedUser = (state: AuthState) => state.user; +const _getAuthenticatedUser = (state: AuthState) => Object.assign(new EPerson(), state.user); /** * Returns the authentication error. diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 25ec1156ee..c344683e38 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -10,7 +10,6 @@ import { AuthService } from './auth.service'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { CheckAuthenticationTokenAction } from './auth.actions'; import { EPerson } from '../eperson/models/eperson.model'; -import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; /** * The auth service. @@ -35,15 +34,10 @@ export class ServerAuthService extends AuthService { options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( + map((status) => this.rdbService.build(status)), switchMap((status: AuthStatus) => { - if (status.authenticated) { - - // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... - const person$ = this.rdbService.buildSingle(status.eperson.toString()); - return person$.pipe( - map((eperson) => eperson.payload) - ); + return status.eperson.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); } diff --git a/src/app/core/browse/browse-entry-search-options.model.ts b/src/app/core/browse/browse-entry-search-options.model.ts new file mode 100644 index 0000000000..417bf7ce75 --- /dev/null +++ b/src/app/core/browse/browse-entry-search-options.model.ts @@ -0,0 +1,18 @@ +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortOptions } from '../cache/models/sort-options.model'; + +/** + * A class that defines the search options to be used for fetching browse entries or items + * - metadataDefinition: The metadata definition to fetch entries or items for + * - pagination: Optional pagination options to use + * - sort: Optional sorting options to use + * - scope: An optional scope to limit the results within a specific collection or community + */ +export class BrowseEntrySearchOptions { + constructor(public metadataDefinition: string, + public pagination?: PaginationComponentOptions, + public sort?: SortOptions, + public startsWith?: string, + public scope?: string) { + } +} diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index da75e1a877..725b371c14 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -8,6 +8,7 @@ import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseService } from './browse.service'; +import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { RequestEntry } from '../data/request.reducer'; import { of as observableOf } from 'rxjs'; @@ -113,7 +114,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseDefinitions().subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { @@ -151,14 +152,14 @@ describe('BrowseService', () => { it('should configure a new BrowseEntriesRequest', () => { const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries); - scheduler.schedule(() => service.getBrowseEntriesFor(browseDefinitions[1].id).subscribe()); + scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getBrowseEntriesFor(browseDefinitions[1].id); + service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); @@ -170,14 +171,14 @@ describe('BrowseService', () => { it('should configure a new BrowseItemsRequest', () => { const expected = new BrowseItemsRequest(requestService.generateRequestId(), browseDefinitions[1]._links.items + '?filterValue=' + mockAuthorName); - scheduler.schedule(() => service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName).subscribe()); + scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName); + service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(browseDefinitions[1].id)); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); @@ -189,9 +190,9 @@ describe('BrowseService', () => { it('should throw an Error', () => { const definitionID = 'invalidID'; - const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) + const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)); - expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected); + expect(service.getBrowseEntriesFor(new BrowseEntrySearchOptions(definitionID))).toBeObservable(expected); }); }); @@ -201,7 +202,7 @@ describe('BrowseService', () => { const definitionID = 'invalidID'; const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) - expect(service.getBrowseItemsFor(definitionID, mockAuthorName)).toBeObservable(expected); + expect(service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(definitionID))).toBeObservable(expected); }); }); }); @@ -219,44 +220,44 @@ describe('BrowseService', () => { }})); }); - it('should return the URL for the given metadatumKey and linkPath', () => { - const metadatumKey = 'dc.date.issued'; + it('should return the URL for the given metadataKey and linkPath', () => { + const metadataKey = 'dc.date.issued'; const linkPath = 'items'; const expectedURL = browseDefinitions[0]._links[linkPath]; - const result = service.getBrowseURLFor(metadatumKey, linkPath); + const result = service.getBrowseURLFor(metadataKey, linkPath); const expected = cold('c-d-', { c: undefined, d: expectedURL }); expect(result).toBeObservable(expected); }); - it('should work when the definition uses a wildcard in the metadatumKey', () => { - const metadatumKey = 'dc.contributor.author'; // should match dc.contributor.* in the definition + it('should work when the definition uses a wildcard in the metadataKey', () => { + const metadataKey = 'dc.contributor.author'; // should match dc.contributor.* in the definition const linkPath = 'items'; const expectedURL = browseDefinitions[1]._links[linkPath]; - const result = service.getBrowseURLFor(metadatumKey, linkPath); + const result = service.getBrowseURLFor(metadataKey, linkPath); const expected = cold('c-d-', { c: undefined, d: expectedURL }); expect(result).toBeObservable(expected); }); it('should throw an error when the key doesn\'t match', () => { - const metadatumKey = 'dc.title'; // isn't in the definitions + const metadataKey = 'dc.title'; // isn't in the definitions const linkPath = 'items'; - const result = service.getBrowseURLFor(metadatumKey, linkPath); - const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`)); + const result = service.getBrowseURLFor(metadataKey, linkPath); + const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`)); expect(result).toBeObservable(expected); }); it('should throw an error when the link doesn\'t match', () => { - const metadatumKey = 'dc.date.issued'; + const metadataKey = 'dc.date.issued'; const linkPath = 'collections'; // isn't in the definitions - const result = service.getBrowseURLFor(metadatumKey, linkPath); - const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`)); + const result = service.getBrowseURLFor(metadataKey, linkPath); + const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`)); expect(result).toBeObservable(expected); }); @@ -271,13 +272,47 @@ describe('BrowseService', () => { spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('----')); - const metadatumKey = 'dc.date.issued'; + const metadataKey = 'dc.date.issued'; const linkPath = 'items'; - const result = service.getBrowseURLFor(metadatumKey, linkPath); + const result = service.getBrowseURLFor(metadataKey, linkPath); const expected = cold('b---', { b: undefined }); expect(result).toBeObservable(expected); }); }); }); + + describe('getFirstItemFor', () => { + beforeEach(() => { + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + service = initTestService(); + spyOn(service, 'getBrowseDefinitions').and + .returnValue(hot('--a-', { a: { + payload: browseDefinitions + }})); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + }); + + describe('when getFirstItemFor is called with a valid browse definition id', () => { + const expectedURL = browseDefinitions[1]._links.items + '?page=0&size=1'; + + it('should configure a new BrowseItemsRequest', () => { + const expected = new BrowseItemsRequest(requestService.generateRequestId(), expectedURL); + + scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getFirstItemFor(browseDefinitions[1].id); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + }); + + }); + }); + }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index b807a77e99..bf368e37ce 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,17 +1,14 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, map, startWith, take } from 'rxjs/operators'; import { - ensureArrayHasValue, + ensureArrayHasValue, hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortOptions } from '../cache/models/sort-options.model'; -import { GenericSuccessResponse } from '../cache/response.models'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; import { @@ -26,20 +23,26 @@ import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, - filterSuccessfulResponses, - getBrowseDefinitionLinks, - getRemoteDataPayload, getRequestFromRequestHref + filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence, + getRemoteDataPayload, + getRequestFromRequestHref } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; +import { GenericSuccessResponse } from '../cache/response.models'; +import { RequestEntry } from '../data/request.reducer'; +/** + * The service handling all browse requests + */ @Injectable() export class BrowseService { protected linkPath = 'browses'; - private static toSearchKeyArray(metadatumKey: string): string[] { - const keyParts = metadatumKey.split('.'); + private static toSearchKeyArray(metadataKey: string): string[] { + const keyParts = metadataKey.split('.'); const searchFor = []; searchFor.push('*'); for (let i = 0; i < keyParts.length - 1; i++) { @@ -47,7 +50,7 @@ export class BrowseService { const nextPart = [...prevParts, '*'].join('.'); searchFor.push(nextPart); } - searchFor.push(metadatumKey); + searchFor.push(metadataKey); return searchFor; } @@ -58,6 +61,9 @@ export class BrowseService { ) { } + /** + * Get all BrowseDefinitions + */ getBrowseDefinitions(): Observable> { const request$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), @@ -80,18 +86,22 @@ export class BrowseService { return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } - getBrowseEntriesFor(definitionID: string, options: { - pagination?: PaginationComponentOptions; - sort?: SortOptions; - } = {}): Observable>> { - const request$ = this.getBrowseDefinitions().pipe( - getBrowseDefinitionLinks(definitionID), + /** + * Get all BrowseEntries filtered or modified by BrowseEntrySearchOptions + * @param options + */ + getBrowseEntriesFor(options: BrowseEntrySearchOptions): Observable>> { + return this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.entries), hasValueOperator(), map((href: string) => { // TODO nearly identical to PaginatedSearchOptions => refactor const args = []; + if (isNotEmpty(options.scope)) { + args.push(`scope=${options.scope}`); + } if (isNotEmpty(options.sort)) { args.push(`sort=${options.sort.field},${options.sort.direction}`); } @@ -99,51 +109,35 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(args)) { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; }), - map((endpointURL: string) => new BrowseEntriesRequest(this.requestService.generateRequestId(), endpointURL)), - configureRequest(this.requestService) + getBrowseEntriesFor(this.requestService, this.rdb) ); - - const href$ = request$.pipe(map((request: RestRequest) => request.href)); - - const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - - const payload$ = requestEntry$.pipe( - filterSuccessfulResponses(), - map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), - map((list: PaginatedList) => Object.assign(list, { - page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page - })), - distinctUntilChanged() - ); - - return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } /** * Get all items linked to a certain metadata value - * @param {string} definitionID definition ID to define the metadata-field (e.g. author) * @param {string} filterValue metadata value to filter by (e.g. author's name) - * @param options Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } + * @param options Options to narrow down your search * @returns {Observable>>} */ - getBrowseItemsFor(definitionID: string, filterValue: string, options: { - pagination?: PaginationComponentOptions; - sort?: SortOptions; - } = {}): Observable>> { - const request$ = this.getBrowseDefinitions().pipe( - getBrowseDefinitionLinks(definitionID), + getBrowseItemsFor(filterValue: string, options: BrowseEntrySearchOptions): Observable>> { + return this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.items), hasValueOperator(), map((href: string) => { const args = []; + if (isNotEmpty(options.scope)) { + args.push(`scope=${options.scope}`); + } if (isNotEmpty(options.sort)) { args.push(`sort=${options.sort.field},${options.sort.direction}`); } @@ -151,6 +145,9 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(filterValue)) { args.push(`filterValue=${filterValue}`); } @@ -159,28 +156,85 @@ export class BrowseService { } return href; }), - map((endpointURL: string) => new BrowseItemsRequest(this.requestService.generateRequestId(), endpointURL)), - configureRequest(this.requestService) + getBrowseItemsFor(this.requestService, this.rdb) ); - - const href$ = request$.pipe(map((request: RestRequest) => request.href)); - - const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - - const payload$ = requestEntry$.pipe( - filterSuccessfulResponses(), - map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), - map((list: PaginatedList) => Object.assign(list, { - page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page - })), - distinctUntilChanged() - ); - - return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } - getBrowseURLFor(metadatumKey: string, linkPath: string): Observable { - const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey); + /** + * Get the first item for a metadata definition in an optional scope + * @param definition + * @param scope + */ + getFirstItemFor(definition: string, scope?: string): Observable> { + return this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(definition), + hasValueOperator(), + map((_links: any) => _links.items), + hasValueOperator(), + map((href: string) => { + const args = []; + if (hasValue(scope)) { + args.push(`scope=${scope}`); + } + args.push('page=0'); + args.push('size=1'); + if (isNotEmpty(args)) { + href = new URLCombiner(href, `?${args.join('&')}`).toString(); + } + return href; + }), + getBrowseItemsFor(this.requestService, this.rdb), + getFirstOccurrence() + ); + } + + /** + * Get the previous page of items using the paginated list's prev link + * @param items + */ + getPrevBrowseItems(items: RemoteData>): Observable>> { + return observableOf(items.payload.prev).pipe( + getBrowseItemsFor(this.requestService, this.rdb) + ); + } + + /** + * Get the next page of items using the paginated list's next link + * @param items + */ + getNextBrowseItems(items: RemoteData>): Observable>> { + return observableOf(items.payload.next).pipe( + getBrowseItemsFor(this.requestService, this.rdb) + ); + } + + /** + * Get the previous page of browse-entries using the paginated list's prev link + * @param entries + */ + getPrevBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.prev).pipe( + getBrowseEntriesFor(this.requestService, this.rdb) + ); + } + + /** + * Get the next page of browse-entries using the paginated list's next link + * @param entries + */ + getNextBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.next).pipe( + getBrowseEntriesFor(this.requestService, this.rdb) + ); + } + + /** + * Get the browse URL by providing a metadatum key and linkPath + * @param metadatumKey + * @param linkPath + */ + getBrowseURLFor(metadataKey: string, linkPath: string): Observable { + const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey); return this.getBrowseDefinitions().pipe( getRemoteDataPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions @@ -191,7 +245,7 @@ export class BrowseService { ), map((def: BrowseDefinition) => { if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { - throw new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`); + throw new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`); } else { return def._links[linkPath]; } @@ -202,3 +256,79 @@ export class BrowseService { } } + +/** + * Operator for turning a href into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ +export const getBrowseEntriesFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new BrowseEntriesRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedBrowseEntries(requestService, rdb) + ); + +/** + * Operator for turning a href into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ +export const getBrowseItemsFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new BrowseItemsRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedBrowseItems(requestService, rdb) + ); + +/** + * Operator for turning a RestRequest into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ +export const toRDPaginatedBrowseItems = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; + +/** + * Operator for turning a RestRequest into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ +export const toRDPaginatedBrowseEntries = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; diff --git a/src/app/core/cache/builders/normalized-object-build.service.ts b/src/app/core/cache/builders/normalized-object-build.service.ts new file mode 100644 index 0000000000..79665fec3d --- /dev/null +++ b/src/app/core/cache/builders/normalized-object-build.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; +import { NormalizedObject } from '../models/normalized-object.model'; +import { CacheableObject } from '../object-cache.reducer'; +import { getRelationships } from './build-decorators'; +import { NormalizedObjectFactory } from '../models/normalized-object-factory'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; + +/** + * Return true if halObj has a value for `_links.self` + * + * @param {any} halObj The object to test + */ +export function isRestDataObject(halObj: any): boolean { + return isNotEmpty(halObj._links) && hasValue(halObj._links.self); +} + +/** + * Return true if halObj has a value for `page` and `_embedded` + * + * @param {any} halObj The object to test + */ +export function isRestPaginatedList(halObj: any): boolean { + return hasValue(halObj.page) && hasValue(halObj._embedded); +} + +/** + * A service to turn domain models in to their normalized + * counterparts. + */ +@Injectable() +export class NormalizedObjectBuildService { + + /** + * Returns the normalized model that corresponds to the given domain model + * + * @param {TDomain} domainModel a domain model + */ + normalize(domainModel: T): NormalizedObject { + const normalizedConstructor = NormalizedObjectFactory.getConstructor(domainModel.type); + const relationships = getRelationships(normalizedConstructor) || []; + + const normalizedModel = Object.assign({}, domainModel) as any; + relationships.forEach((key: string) => { + if (hasValue(domainModel[key])) { + domainModel[key] = undefined; + } + }); + return normalizedModel; + } +} diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index e4444ca803..272969050d 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -8,20 +8,24 @@ import { of as observableOf } from 'rxjs'; const pageInfo = new PageInfo(); const array = [ Object.assign(new Item(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Item nr 1' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Item nr 1' + } + ] + } }), Object.assign(new Item(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Item nr 2' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Item nr 2' + } + ] + } }) ]; const paginatedList = new PaginatedList(pageInfo, array); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 7561fe3aff..b4e52e8426 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,26 +1,15 @@ -import { - combineLatest as observableCombineLatest, - Observable, - of as observableOf, - race as observableRace -} from 'rxjs'; import { Injectable } from '@angular/core'; -import { - distinctUntilChanged, - flatMap, - map, - startWith, - switchMap, - take -} from 'rxjs/operators'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util'; + +import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs'; +import { distinctUntilChanged, flatMap, map, startWith, switchMap, tap } from 'rxjs/operators'; + +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; import { RemoteDataError } from '../../data/remote-data-error'; import { GetRequest } from '../../data/request.models'; import { RequestEntry } from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; - import { NormalizedObject } from '../models/normalized-object.model'; import { ObjectCacheService } from '../object-cache.service'; import { DSOSuccessResponse, ErrorResponse } from '../response.models'; @@ -28,9 +17,11 @@ import { getMapsTo, getRelationMetadata, getRelationships } from './build-decora import { PageInfo } from '../../shared/page-info.model'; import { filterSuccessfulResponses, - getRequestFromRequestHref, getRequestFromRequestUUID, + getRequestFromRequestHref, + getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators'; +import { CacheableObject } from '../object-cache.reducer'; @Injectable() export class RemoteDataBuildService { @@ -38,7 +29,7 @@ export class RemoteDataBuildService { protected requestService: RequestService) { } - buildSingle(href$: string | Observable): Observable> { + buildSingle(href$: string | Observable): Observable> { if (typeof href$ === 'string') { href$ = observableOf(href$); } @@ -50,21 +41,18 @@ export class RemoteDataBuildService { const requestEntry$ = observableRace( href$.pipe(getRequestFromRequestHref(this.requestService)), requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)), - ).pipe( - take(1) ); - // always use self link if that is cached, only if it isn't, get it via the response. const payload$ = observableCombineLatest( href$.pipe( - switchMap((href: string) => this.objectCache.getBySelfLink(href)), + switchMap((href: string) => this.objectCache.getObjectBySelfLink(href)), startWith(undefined)), requestEntry$.pipe( getResourceLinksFromResponse(), switchMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { - return this.objectCache.getBySelfLink(resourceSelfLinks[0]); + return this.objectCache.getObjectBySelfLink(resourceSelfLinks[0]); } else { return observableOf(undefined); } @@ -81,8 +69,8 @@ export class RemoteDataBuildService { } }), hasValueOperator(), - map((normalized: TNormalized) => { - return this.build(normalized); + map((normalized: NormalizedObject) => { + return this.build(normalized); }), startWith(undefined), distinctUntilChanged() @@ -101,7 +89,11 @@ export class RemoteDataBuildService { isSuccessful = reqEntry.response.isSuccessful; const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; if (hasValue(errorMessage)) { - error = new RemoteDataError(reqEntry.response.statusCode, errorMessage); + error = new RemoteDataError( + (reqEntry.response as ErrorResponse).statusCode, + (reqEntry.response as ErrorResponse).statusText, + errorMessage + ); } } return new RemoteData( @@ -115,7 +107,7 @@ export class RemoteDataBuildService { ); } - buildList(href$: string | Observable): Observable>> { + buildList(href$: string | Observable): Observable>> { if (typeof href$ === 'string') { href$ = observableOf(href$); } @@ -125,9 +117,9 @@ export class RemoteDataBuildService { getResourceLinksFromResponse(), flatMap((resourceUUIDs: string[]) => { return this.objectCache.getList(resourceUUIDs).pipe( - map((normList: TNormalized[]) => { - return normList.map((normalized: TNormalized) => { - return this.build(normalized); + map((normList: Array>) => { + return normList.map((normalized: NormalizedObject) => { + return this.build(normalized); }); })); }), @@ -157,7 +149,7 @@ export class RemoteDataBuildService { return this.toRemoteDataObservable(requestEntry$, payload$); } - build(normalized: TNormalized): TDomain { + build(normalized: NormalizedObject): T { const links: any = {}; const relationships = getRelationships(normalized.constructor) || []; @@ -213,17 +205,28 @@ export class RemoteDataBuildService { return observableCombineLatest(...input).pipe( map((arr) => { + // The request of an aggregate RD should be pending if at least one + // of the RDs it's based on is still in the state RequestPending const requestPending: boolean = arr .map((d: RemoteData) => d.isRequestPending) - .every((b: boolean) => b === true); + .find((b: boolean) => b === true); - const responsePending: boolean = arr + // The response of an aggregate RD should be pending if no requests + // are still pending and at least one of the RDs it's based + // on is still in the state ResponsePending + const responsePending: boolean = !requestPending && arr .map((d: RemoteData) => d.isResponsePending) - .every((b: boolean) => b === true); + .find((b: boolean) => b === true); - const isSuccessful: boolean = arr - .map((d: RemoteData) => d.hasSucceeded) - .every((b: boolean) => b === true); + let isSuccessful: boolean; + // isSuccessful should be undefined until all responses have come in. + // We can't know its state beforehand. We also can't say it's false + // because that would imply a request failed. + if (!(requestPending || responsePending)) { + isSuccessful = arr + .map((d: RemoteData) => d.hasSucceeded) + .every((b: boolean) => b === true); + } const errorMessage: string = arr .map((d: RemoteData) => d.error) @@ -234,16 +237,25 @@ export class RemoteDataBuildService { }).filter((e: string) => hasValue(e)) .join(', '); - const statusCode: string = arr + const statusText: string = arr .map((d: RemoteData) => d.error) .map((e: RemoteDataError, idx: number) => { if (hasValue(e)) { - return `[${idx}]: ${e.statusCode}`; + return `[${idx}]: ${e.statusText}`; } }).filter((c: string) => hasValue(c)) .join(', '); - const error = new RemoteDataError(statusCode, errorMessage); + const statusCode: number = arr + .map((d: RemoteData) => d.error) + .map((e: RemoteDataError, idx: number) => { + if (hasValue(e)) { + return e.statusCode; + } + }).filter((c: number) => hasValue(c)) + .reduce((acc, status) => status, undefined); + + const error = new RemoteDataError(statusCode, statusText, errorMessage); const payload: T[] = arr.map((d: RemoteData) => d.payload); @@ -262,8 +274,10 @@ export class RemoteDataBuildService { map((rd: RemoteData>) => { if (Array.isArray(rd.payload)) { return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload) }) - } else { + } else if (isNotUndefined(rd.payload)) { return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload.page) }); + } else { + return Object.assign(rd, { payload: new PaginatedList(pageInfo, []) }); } }) ); diff --git a/src/app/core/cache/models/items/normalized-item-type.model.ts b/src/app/core/cache/models/items/normalized-item-type.model.ts new file mode 100644 index 0000000000..ed38d80a4b --- /dev/null +++ b/src/app/core/cache/models/items/normalized-item-type.model.ts @@ -0,0 +1,32 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { ItemType } from '../../../shared/item-relationships/item-type.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo } from '../../builders/build-decorators'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace ItemType + */ +@mapsTo(ItemType) +@inheritSerialization(NormalizedObject) +export class NormalizedItemType extends NormalizedObject { + + /** + * The label that describes the ResourceType of the Item + */ + @autoserialize + label: string; + + /** + * The identifier of this ItemType + */ + @autoserialize + id: string; + + /** + * The universally unique identifier of this ItemType + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.ItemType), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/items/normalized-relationship-type.model.ts b/src/app/core/cache/models/items/normalized-relationship-type.model.ts new file mode 100644 index 0000000000..d201fb2746 --- /dev/null +++ b/src/app/core/cache/models/items/normalized-relationship-type.model.ts @@ -0,0 +1,77 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { RelationshipType } from '../../../shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo, relationship } from '../../builders/build-decorators'; +import { NormalizedDSpaceObject } from '../normalized-dspace-object.model'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace RelationshipType + */ +@mapsTo(RelationshipType) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedRelationshipType extends NormalizedObject { + + /** + * The identifier of this RelationshipType + */ + @autoserialize + id: string; + + /** + * The label that describes the Relation to the left of this RelationshipType + */ + @autoserialize + leftLabel: string; + + /** + * The maximum amount of Relationships allowed to the left of this RelationshipType + */ + @autoserialize + leftMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the left of this RelationshipType + */ + @autoserialize + leftMinCardinality: number; + + /** + * The label that describes the Relation to the right of this RelationshipType + */ + @autoserialize + rightLabel: string; + + /** + * The maximum amount of Relationships allowed to the right of this RelationshipType + */ + @autoserialize + rightMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the right of this RelationshipType + */ + @autoserialize + rightMinCardinality: number; + + /** + * The type of Item found to the left of this RelationshipType + */ + @autoserialize + @relationship(ResourceType.ItemType, false) + leftType: string; + + /** + * The type of Item found to the right of this RelationshipType + */ + @autoserialize + @relationship(ResourceType.ItemType, false) + rightType: string; + + /** + * The universally unique identifier of this RelationshipType + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.RelationshipType), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/items/normalized-relationship.model.ts b/src/app/core/cache/models/items/normalized-relationship.model.ts new file mode 100644 index 0000000000..b908426361 --- /dev/null +++ b/src/app/core/cache/models/items/normalized-relationship.model.ts @@ -0,0 +1,57 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { Relationship } from '../../../shared/item-relationships/relationship.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo, relationship } from '../../builders/build-decorators'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace Relationship + */ +@mapsTo(Relationship) +@inheritSerialization(NormalizedObject) +export class NormalizedRelationship extends NormalizedObject { + + /** + * The identifier of this Relationship + */ + @autoserialize + id: string; + + /** + * The identifier of the Item to the left side of this Relationship + */ + @autoserialize + leftId: string; + + /** + * The identifier of the Item to the right side of this Relationship + */ + @autoserialize + rightId: string; + + /** + * The place of the Item to the left side of this Relationship + */ + @autoserialize + leftPlace: number; + + /** + * The place of the Item to the right side of this Relationship + */ + @autoserialize + rightPlace: number; + + /** + * The type of Relationship + */ + @autoserialize + @relationship(ResourceType.RelationshipType, false) + relationshipType: string; + + /** + * The universally unique identifier of this Relationship + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.Relationship), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/normalized-bitstream-format.model.ts b/src/app/core/cache/models/normalized-bitstream-format.model.ts index 5d11c97107..994792d535 100644 --- a/src/app/core/cache/models/normalized-bitstream-format.model.ts +++ b/src/app/core/cache/models/normalized-bitstream-format.model.ts @@ -11,7 +11,7 @@ import { SupportLevel } from './support-level.model'; */ @mapsTo(BitstreamFormat) @inheritSerialization(NormalizedObject) -export class NormalizedBitstreamFormat extends NormalizedObject { +export class NormalizedBitstreamFormat extends NormalizedObject { /** * Short description of this Bitstream Format diff --git a/src/app/core/cache/models/normalized-bitstream.model.ts b/src/app/core/cache/models/normalized-bitstream.model.ts index 63f84add41..64a17aae84 100644 --- a/src/app/core/cache/models/normalized-bitstream.model.ts +++ b/src/app/core/cache/models/normalized-bitstream.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Bitstream) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBitstream extends NormalizedDSpaceObject { +export class NormalizedBitstream extends NormalizedDSpaceObject { /** * The size of this bitstream in bytes diff --git a/src/app/core/cache/models/normalized-bundle.model.ts b/src/app/core/cache/models/normalized-bundle.model.ts index 5535ab57e5..342b13629f 100644 --- a/src/app/core/cache/models/normalized-bundle.model.ts +++ b/src/app/core/cache/models/normalized-bundle.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Bundle) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBundle extends NormalizedDSpaceObject { +export class NormalizedBundle extends NormalizedDSpaceObject { /** * The primary bitstream of this Bundle */ diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts index a2c634c3e5..ddfcc29a2c 100644 --- a/src/app/core/cache/models/normalized-collection.model.ts +++ b/src/app/core/cache/models/normalized-collection.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { Collection } from '../../shared/collection.model'; @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Collection) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedCollection extends NormalizedDSpaceObject { +export class NormalizedCollection extends NormalizedDSpaceObject { /** * A string representing the unique handle of this Collection @@ -19,30 +19,44 @@ export class NormalizedCollection extends NormalizedDSpaceObject { handle: string; /** - * The Bitstream that represents the logo of this Collection + * The Bitstream that represents the license of this Collection */ @autoserialize + @relationship(ResourceType.License, false) + license: string; + + /** + * The Bitstream that represents the default Access Conditions of this Collection + */ + @autoserialize + @relationship(ResourceType.ResourcePolicy, false) + defaultAccessConditions: string; + + /** + * The Bitstream that represents the logo of this Collection + */ + @deserialize @relationship(ResourceType.Bitstream, false) logo: string; /** * An array of Communities that are direct parents of this Collection */ - @autoserialize + @deserialize @relationship(ResourceType.Community, true) parents: string[]; /** * The Community that owns this Collection */ - @autoserialize + @deserialize @relationship(ResourceType.Community, false) owner: string; /** * List of Items that are part of (not necessarily owned by) this Collection */ - @autoserialize + @deserialize @relationship(ResourceType.Item, true) items: string[]; diff --git a/src/app/core/cache/models/normalized-community.model.ts b/src/app/core/cache/models/normalized-community.model.ts index 4ab2408a53..f561089949 100644 --- a/src/app/core/cache/models/normalized-community.model.ts +++ b/src/app/core/cache/models/normalized-community.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, inheritSerialization, serialize } from 'cerialize'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { Community } from '../../shared/community.model'; @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Community) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedCommunity extends NormalizedDSpaceObject { +export class NormalizedCommunity extends NormalizedDSpaceObject { /** * A string representing the unique handle of this Community @@ -21,32 +21,32 @@ export class NormalizedCommunity extends NormalizedDSpaceObject { /** * The Bitstream that represents the logo of this Community */ - @autoserialize + @deserialize @relationship(ResourceType.Bitstream, false) logo: string; /** * An array of Communities that are direct parents of this Community */ - @autoserialize + @deserialize @relationship(ResourceType.Community, true) parents: string[]; /** * The Community that owns this Community */ - @autoserialize + @deserialize @relationship(ResourceType.Community, false) owner: string; /** * List of Collections that are owned by this Community */ - @autoserialize + @deserialize @relationship(ResourceType.Collection, true) collections: string[]; - @autoserialize + @deserialize @relationship(ResourceType.Community, true) subcommunities: string[]; diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts index 92174c40f7..e12faa4a77 100644 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -1,7 +1,6 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; +import { autoserializeAs, deserializeAs } from 'cerialize'; import { DSpaceObject } from '../../shared/dspace-object.model'; - -import { Metadatum } from '../../shared/metadatum.model'; +import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; import { ResourceType } from '../../shared/resource-type'; import { mapsTo } from '../builders/build-decorators'; import { NormalizedObject } from './normalized-object.model'; @@ -10,7 +9,7 @@ import { NormalizedObject } from './normalized-object.model'; * An model class for a DSpaceObject. */ @mapsTo(DSpaceObject) -export class NormalizedDSpaceObject extends NormalizedObject { +export class NormalizedDSpaceObject extends NormalizedObject { /** * The link to the rest endpoint where this object can be found @@ -18,7 +17,7 @@ export class NormalizedDSpaceObject extends NormalizedObject { * Repeated here to make the serialization work, * inheritSerialization doesn't seem to work for more than one level */ - @autoserialize + @deserializeAs(String) self: string; /** @@ -32,41 +31,32 @@ export class NormalizedDSpaceObject extends NormalizedObject { /** * The universally unique identifier of this DSpaceObject - * - * Repeated here to make the serialization work, - * inheritSerialization doesn't seem to work for more than one level */ - @autoserialize + @autoserializeAs(String) uuid: string; /** * A string representing the kind of DSpaceObject, e.g. community, item, … */ - @autoserialize + @autoserializeAs(String) type: ResourceType; /** - * The name for this DSpaceObject + * All metadata of this DSpaceObject */ - @autoserialize - name: string; - - /** - * An array containing all metadata of this DSpaceObject - */ - @autoserializeAs(Metadatum) - metadata: Metadatum[]; + @autoserializeAs(MetadataMapSerializer) + metadata: MetadataMap; /** * An array of DSpaceObjects that are direct parents of this DSpaceObject */ - @autoserialize + @deserializeAs(String) parents: string[]; /** * The DSpaceObject that owns this DSpaceObject */ - @autoserialize + @deserializeAs(String) owner: string; /** @@ -75,7 +65,7 @@ export class NormalizedDSpaceObject extends NormalizedObject { * Repeated here to make the serialization work, * inheritSerialization doesn't seem to work for more than one level */ - @autoserialize + @deserializeAs(Object) _links: { [name: string]: string } diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts index 7d518bd048..d2b7b9c92d 100644 --- a/src/app/core/cache/models/normalized-item.model.ts +++ b/src/app/core/cache/models/normalized-item.model.ts @@ -1,4 +1,4 @@ -import { inheritSerialization, autoserialize, autoserializeAs } from 'cerialize'; +import { inheritSerialization, deserialize, autoserialize, autoserializeAs } from 'cerialize'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { Item } from '../../shared/item.model'; @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Item) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedItem extends NormalizedDSpaceObject { +export class NormalizedItem extends NormalizedDSpaceObject { /** * A string representing the unique handle of this Item @@ -21,7 +21,7 @@ export class NormalizedItem extends NormalizedDSpaceObject { /** * The Date of the last modification of this Item */ - @autoserialize + @deserialize lastModified: Date; /** @@ -45,22 +45,26 @@ export class NormalizedItem extends NormalizedDSpaceObject { /** * An array of Collections that are direct parents of this Item */ - @autoserialize + @deserialize @relationship(ResourceType.Collection, true) parents: string[]; /** * The Collection that owns this Item */ - @autoserialize + @deserialize @relationship(ResourceType.Collection, false) owningCollection: string; /** * List of Bitstreams that are owned by this Item */ - @autoserialize + @deserialize @relationship(ResourceType.Bitstream, true) bitstreams: string[]; + @autoserialize + @relationship(ResourceType.Relationship, true) + relationships: string[]; + } diff --git a/src/app/core/cache/models/normalized-license.model.ts b/src/app/core/cache/models/normalized-license.model.ts new file mode 100644 index 0000000000..02bd1808c8 --- /dev/null +++ b/src/app/core/cache/models/normalized-license.model.ts @@ -0,0 +1,24 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo } from '../builders/build-decorators'; +import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; +import { License } from '../../shared/license.model'; + +/** + * Normalized model class for a Collection License + */ +@mapsTo(License) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedLicense extends NormalizedDSpaceObject { + + /** + * A boolean representing if this License is custom or not + */ + @autoserialize + custom: boolean; + + /** + * The text of the license + */ + @autoserialize + text: string; +} diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index 5c5ebf50aa..aa1f6f2958 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -1,3 +1,6 @@ +import { NormalizedItemType } from './items/normalized-item-type.model'; +import { NormalizedRelationshipType } from './items/normalized-relationship-type.model'; +import { NormalizedRelationship } from './items/normalized-relationship.model'; import { NormalizedBitstream } from './normalized-bitstream.model'; import { NormalizedBundle } from './normalized-bundle.model'; import { NormalizedItem } from './normalized-item.model'; @@ -6,13 +9,23 @@ import { GenericConstructor } from '../../shared/generic-constructor'; import { NormalizedCommunity } from './normalized-community.model'; import { ResourceType } from '../../shared/resource-type'; import { NormalizedObject } from './normalized-object.model'; -import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model'; +import { NormalizedLicense } from './normalized-license.model'; import { NormalizedResourcePolicy } from './normalized-resource-policy.model'; +import { NormalizedWorkspaceItem } from '../../submission/models/normalized-workspaceitem.model'; import { NormalizedEPerson } from '../../eperson/models/normalized-eperson.model'; import { NormalizedGroup } from '../../eperson/models/normalized-group.model'; +import { NormalizedWorkflowItem } from '../../submission/models/normalized-workflowitem.model'; +import { NormalizedClaimedTask } from '../../tasks/models/normalized-claimed-task-object.model'; +import { NormalizedPoolTask } from '../../tasks/models/normalized-pool-task-object.model'; +import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model'; +import { NormalizedMetadataSchema } from '../../metadata/normalized-metadata-schema.model'; +import { CacheableObject } from '../object-cache.reducer'; +import { NormalizedSubmissionDefinitionsModel } from '../../config/models/normalized-config-submission-definitions.model'; +import { NormalizedSubmissionFormsModel } from '../../config/models/normalized-config-submission-forms.model'; +import { NormalizedSubmissionSectionModel } from '../../config/models/normalized-config-submission-section.model'; export class NormalizedObjectFactory { - public static getConstructor(type: ResourceType): GenericConstructor { + public static getConstructor(type: ResourceType): GenericConstructor> { switch (type) { case ResourceType.Bitstream: { return NormalizedBitstream @@ -32,15 +45,57 @@ export class NormalizedObjectFactory { case ResourceType.BitstreamFormat: { return NormalizedBitstreamFormat } + case ResourceType.License: { + return NormalizedLicense + } case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } + case ResourceType.Relationship: { + return NormalizedRelationship + } + case ResourceType.RelationshipType: { + return NormalizedRelationshipType + } + case ResourceType.ItemType: { + return NormalizedItemType + } case ResourceType.EPerson: { return NormalizedEPerson } case ResourceType.Group: { return NormalizedGroup } + case ResourceType.MetadataSchema: { + return NormalizedMetadataSchema + } + case ResourceType.MetadataField: { + return NormalizedGroup + } + case ResourceType.Workspaceitem: { + return NormalizedWorkspaceItem + } + case ResourceType.Workflowitem: { + return NormalizedWorkflowItem + } + case ResourceType.ClaimedTask: { + return NormalizedClaimedTask + } + case ResourceType.PoolTask: { + return NormalizedPoolTask + } + case ResourceType.SubmissionDefinition: + case ResourceType.SubmissionDefinitions: { + return NormalizedSubmissionDefinitionsModel + } + case ResourceType.SubmissionForm: + case ResourceType.SubmissionForms: { + return NormalizedSubmissionFormsModel + } + case ResourceType.SubmissionSection: + case ResourceType.SubmissionSections: { + return NormalizedSubmissionSectionModel + } default: { return undefined; } diff --git a/src/app/core/cache/models/normalized-object.model.ts b/src/app/core/cache/models/normalized-object.model.ts index e98081d68a..6ac8985d64 100644 --- a/src/app/core/cache/models/normalized-object.model.ts +++ b/src/app/core/cache/models/normalized-object.model.ts @@ -4,7 +4,7 @@ import { ResourceType } from '../../shared/resource-type'; /** * An abstract model class for a NormalizedObject. */ -export abstract class NormalizedObject implements CacheableObject { +export abstract class NormalizedObject implements CacheableObject { /** * The link to the rest endpoint where this object can be found @@ -13,11 +13,8 @@ export abstract class NormalizedObject implements CacheableObject { self: string; /** - * The universally unique identifier of this Object + * A string representing the kind of DSpaceObject, e.g. community, item, … */ - @autoserialize - uuid: string; - @autoserialize type: ResourceType; diff --git a/src/app/core/cache/models/normalized-resource-policy.model.ts b/src/app/core/cache/models/normalized-resource-policy.model.ts index b767ca6491..9438c1da0a 100644 --- a/src/app/core/cache/models/normalized-resource-policy.model.ts +++ b/src/app/core/cache/models/normalized-resource-policy.model.ts @@ -1,10 +1,9 @@ import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { ResourcePolicy } from '../../shared/resource-policy.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; +import { mapsTo } from '../builders/build-decorators'; import { NormalizedObject } from './normalized-object.model'; import { IDToUUIDSerializer } from '../id-to-uuid-serializer'; -import { ResourceType } from '../../shared/resource-type'; import { ActionType } from './action-type.model'; /** @@ -12,11 +11,12 @@ import { ActionType } from './action-type.model'; */ @mapsTo(ResourcePolicy) @inheritSerialization(NormalizedObject) -export class NormalizedResourcePolicy extends NormalizedObject { +export class NormalizedResourcePolicy extends NormalizedObject { /** * The action that is allowed by this Resource Policy */ + @autoserialize action: ActionType; /** @@ -28,9 +28,8 @@ export class NormalizedResourcePolicy extends NormalizedObject { /** * The uuid of the Group this Resource Policy applies to */ - @relationship(ResourceType.Group, false) - @autoserializeAs(String, 'groupUUID') - group: string; + @autoserialize + groupUUID: string; /** * Identifier for this Resource Policy @@ -46,4 +45,5 @@ export class NormalizedResourcePolicy extends NormalizedObject { */ @autoserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') uuid: string; + } diff --git a/src/app/core/cache/models/search-param.model.ts b/src/app/core/cache/models/search-param.model.ts new file mode 100644 index 0000000000..a33bbee5e6 --- /dev/null +++ b/src/app/core/cache/models/search-param.model.ts @@ -0,0 +1,9 @@ + +/** + * Class representing a query parameter (query?fieldName=fieldValue) used in FindAllOptions object + */ +export class SearchParam { + constructor(public fieldName: string, public fieldValue: any) { + + } +} diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 867f31e1bb..982c77341e 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -17,12 +17,22 @@ export enum DirtyType { Deleted = 'Deleted' } +/** + * An interface to represent a JsonPatch + */ export interface Patch { + /** + * The identifier for this Patch + */ uuid?: string; + + /** + * the list of operations this Patch is composed of + */ operations: Operation[]; } -/**conca +/** * An interface to represent objects that can be cached * * A cacheable object should have a self link diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index af353a38c1..20e12108ad 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,18 +1,19 @@ +import * as ngrx from '@ngrx/store'; import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { ObjectCacheService } from './object-cache.service'; import { AddPatchObjectCacheAction, - AddToObjectCacheAction, ApplyPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import { CoreState } from '../core.reducers'; import { ResourceType } from '../shared/resource-type'; import { NormalizedItem } from './models/normalized-item.model'; import { first } from 'rxjs/operators'; -import * as ngrx from '@ngrx/store'; -import { Operation } from '../../../../node_modules/fast-json-patch'; +import { Operation } from 'fast-json-patch'; import { RestRequestMethod } from '../data/rest-request-method'; import { AddToSSBAction } from './server-sync-buffer.actions'; import { Patch } from './object-cache.reducer'; @@ -80,7 +81,7 @@ describe('ObjectCacheService', () => { }); // due to the implementation of spyOn above, this subscribe will be synchronous - service.getBySelfLink(selfLink).pipe(first()).subscribe((o) => { + service.getObjectBySelfLink(selfLink).pipe(first()).subscribe((o) => { expect(o.self).toBe(selfLink); // this only works if testObj is an instance of TestClass expect(o instanceof NormalizedItem).toBeTruthy(); @@ -96,7 +97,7 @@ describe('ObjectCacheService', () => { }); let getObsHasFired = false; - const subscription = service.getBySelfLink(selfLink).subscribe((o) => getObsHasFired = true); + const subscription = service.getObjectBySelfLink(selfLink).subscribe((o) => getObsHasFired = true); expect(getObsHasFired).toBe(false); subscription.unsubscribe(); }); @@ -106,7 +107,7 @@ describe('ObjectCacheService', () => { it('should return an observable of the array of cached objects with the specified self link and type', () => { const item = new NormalizedItem(); item.self = selfLink; - spyOn(service, 'getBySelfLink').and.returnValue(observableOf(item)); + spyOn(service, 'getObjectBySelfLink').and.returnValue(observableOf(item)); service.getList([selfLink, selfLink]).pipe(first()).subscribe((arr) => { expect(arr[0].self).toBe(selfLink); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index af30646f53..e6384571c3 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,34 +1,44 @@ +import { Injectable } from '@angular/core'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { applyPatch, Operation } from 'fast-json-patch'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { MemoizedSelector, select, Store } from '@ngrx/store'; -import { IndexName } from '../index/index.reducer'; - -import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer'; +import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; +import { CoreState } from '../core.reducers'; +import { coreSelector } from '../core.selectors'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { selfLinkFromUuidSelector } from '../index/index.selectors'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { NormalizedObjectFactory } from './models/normalized-object-factory'; +import { NormalizedObject } from './models/normalized-object.model'; import { AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; -import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { coreSelector, CoreState } from '../core.reducers'; -import { pathSelector } from '../shared/selectors'; -import { NormalizedObjectFactory } from './models/normalized-object-factory'; -import { NormalizedObject } from './models/normalized-object.model'; -import { applyPatch, Operation } from 'fast-json-patch'; + +import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RestRequestMethod } from '../data/rest-request-method'; -function selfLinkFromUuidSelector(uuid: string): MemoizedSelector { - return pathSelector(coreSelector, 'index', IndexName.OBJECT, uuid); -} +/** + * The base selector function to select the object cache in the store + */ +const objectCacheSelector = createSelector( + coreSelector, + (state: CoreState) => state['cache/object'] +); -function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector { - return pathSelector(coreSelector, 'cache/object', selfLink); -} +/** + * Selector function to select an object entry by self link from the cache + * @param selfLink The self link of the object + */ +const entryFromSelfLinkSelector = + (selfLink: string): MemoizedSelector => createSelector( + objectCacheSelector, + (state: ObjectCacheState) => state[selfLink], + ); /** * A service to interact with the object cache @@ -65,29 +75,29 @@ export class ObjectCacheService { /** * Get an observable of the object with the specified UUID * - * The type needs to be specified as well, in order to turn - * the cached plain javascript object in to an instance of - * a class. - * - * e.g. getByUUID('c96588c6-72d3-425d-9d47-fa896255a695', Item) - * * @param uuid * The UUID of the object to get - * @param type - * The type of the object to get - * @return Observable - * An observable of the requested object + * @return Observable> + * An observable of the requested object in normalized form */ - getByUUID(uuid: string): Observable { + getObjectByUUID(uuid: string): Observable> { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getBySelfLink(selfLink) + mergeMap((selfLink: string) => this.getObjectBySelfLink(selfLink) ) ) } - getBySelfLink(selfLink: string): Observable { - return this.getEntry(selfLink).pipe( + /** + * Get an observable of the object with the specified selfLink + * + * @param selfLink + * The selfLink of the object to get + * @return Observable> + * An observable of the requested object in normalized form + */ + getObjectBySelfLink(selfLink: string): Observable> { + return this.getBySelfLink(selfLink).pipe( map((entry: ObjectCacheEntry) => { if (isNotEmpty(entry.patches)) { const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); @@ -99,13 +109,21 @@ export class ObjectCacheService { } ), map((entry: ObjectCacheEntry) => { - const type: GenericConstructor = NormalizedObjectFactory.getConstructor(entry.data.type); - return Object.assign(new type(), entry.data) as T + const type: GenericConstructor> = NormalizedObjectFactory.getConstructor(entry.data.type); + return Object.assign(new type(), entry.data) as NormalizedObject }) ); } - private getEntry(selfLink: string): Observable { + /** + * Get an observable of the object cache entry with the specified selfLink + * + * @param selfLink + * The selfLink of the object to get + * @return Observable + * An observable of the requested object cache entry + */ + getBySelfLink(selfLink: string): Observable { return this.store.pipe( select(entryFromSelfLinkSelector(selfLink)), filter((entry) => this.isValid(entry)), @@ -113,12 +131,28 @@ export class ObjectCacheService { ); } + /** + * Get an observable of the request's uuid with the specified selfLink + * + * @param selfLink + * The selfLink of the object to get + * @return Observable + * An observable of the request's uuid + */ getRequestUUIDBySelfLink(selfLink: string): Observable { - return this.getEntry(selfLink).pipe( + return this.getBySelfLink(selfLink).pipe( map((entry: ObjectCacheEntry) => entry.requestUUID), distinctUntilChanged()); } + /** + * Get an observable of the request's uuid with the specified uuid + * + * @param uuid + * The uuid of the object to get + * @return Observable + * An observable of the request's uuid + */ getRequestUUIDByObjectUUID(uuid: string): Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), @@ -145,9 +179,9 @@ export class ObjectCacheService { * The type of the objects to get * @return Observable> */ - getList(selfLinks: string[]): Observable { + getList(selfLinks: string[]): Observable>> { return observableCombineLatest( - selfLinks.map((selfLink: string) => this.getBySelfLink(selfLink)) + selfLinks.map((selfLink: string) => this.getObjectBySelfLink(selfLink)) ); } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 34fc22bb0a..b3523addc5 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,23 +1,29 @@ import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; -import { ConfigObject } from '../shared/config/config.model'; +import { ConfigObject } from '../config/models/config.model'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.model'; import { IntegrationModel } from '../integration/models/integration.model'; import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; -import { MetadataSchema } from '../metadata/metadataschema.model'; import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; -import { AuthStatus } from '../auth/models/auth-status.model'; +import { MetadataSchema } from '../metadata/metadataschema.model'; +import { MetadataField } from '../metadata/metadatafield.model'; +import { PaginatedList } from '../data/paginated-list'; +import { SubmissionObject } from '../submission/models/submission-object.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { + public toCache = true; public timeAdded: number; constructor( public isSuccessful: boolean, - public statusCode: string, + public statusCode: number, + public statusText: string ) { } } @@ -25,68 +31,100 @@ export class RestResponse { export class DSOSuccessResponse extends RestResponse { constructor( public resourceSelfLinks: string[], - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } +/** + * A successful response containing a list of MetadataSchemas wrapped in a RegistryMetadataschemasResponse + */ export class RegistryMetadataschemasSuccessResponse extends RestResponse { constructor( public metadataschemasResponse: RegistryMetadataschemasResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } +/** + * A successful response containing a list of MetadataFields wrapped in a RegistryMetadatafieldsResponse + */ export class RegistryMetadatafieldsSuccessResponse extends RestResponse { constructor( public metadatafieldsResponse: RegistryMetadatafieldsResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } +/** + * A successful response containing a list of BitstreamFormats wrapped in a RegistryBitstreamformatsResponse + */ export class RegistryBitstreamformatsSuccessResponse extends RestResponse { constructor( public bitstreamformatsResponse: RegistryBitstreamformatsResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } +/** + * A successful response containing exactly one MetadataSchema + */ export class MetadataschemaSuccessResponse extends RestResponse { constructor( public metadataschema: MetadataSchema, - public statusCode: string + public statusCode: number, + public statusText: string, ) { - super(true, statusCode); + super(true, statusCode, statusText); + } +} + +/** + * A successful response containing exactly one MetadataField + */ +export class MetadatafieldSuccessResponse extends RestResponse { + constructor( + public metadatafield: MetadataField, + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); } } export class SearchSuccessResponse extends RestResponse { constructor( public results: SearchQueryResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class FacetConfigSuccessResponse extends RestResponse { constructor( public results: SearchFilterConfig[], - public statusCode: string + public statusCode: number, + public statusText: string, ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -97,18 +135,20 @@ export class FacetValueMap { export class FacetValueSuccessResponse extends RestResponse { constructor( public results: FacetValue[], - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class FacetValueMapSuccessResponse extends RestResponse { constructor( public results: FacetValueMap, - public statusCode: string, + public statusCode: number, + public statusText: string ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -119,19 +159,21 @@ export class EndpointMap { export class EndpointMapSuccessResponse extends RestResponse { constructor( public endpointMap: EndpointMap, - public statusCode: string, + public statusCode: number, + public statusText: string ) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class GenericSuccessResponse extends RestResponse { constructor( public payload: T, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -139,7 +181,7 @@ export class ErrorResponse extends RestResponse { errorMessage: string; constructor(error: RequestError) { - super(false, error.statusText); + super(false, error.statusCode, error.statusText); console.error(error); this.errorMessage = error.message; } @@ -147,11 +189,12 @@ export class ErrorResponse extends RestResponse { export class ConfigSuccessResponse extends RestResponse { constructor( - public configDefinition: ConfigObject[], - public statusCode: string, + public configDefinition: ConfigObject, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -159,21 +202,90 @@ export class AuthStatusResponse extends RestResponse { public toCache = false; constructor( - public response: AuthStatus, - public statusCode: string + public response: NormalizedAuthStatus, + public statusCode: number, + public statusText: string, ) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class IntegrationSuccessResponse extends RestResponse { constructor( - public dataDefinition: IntegrationModel[], - public statusCode: string, + public dataDefinition: PaginatedList, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } +export class PostPatchSuccessResponse extends RestResponse { + constructor( + public dataDefinition: any, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class SubmissionSuccessResponse extends RestResponse { + constructor( + public dataDefinition: Array, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class EpersonSuccessResponse extends RestResponse { + constructor( + public epersonDefinition: DSpaceObject[], + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class MessageResponse extends RestResponse { + public toCache = false; + + constructor( + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class TaskResponse extends RestResponse { + public toCache = false; + + constructor( + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class FilteredDiscoveryQueryResponse extends RestResponse { + constructor( + public filterQuery: string, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index 0a8d50107e..773e0ab60c 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -1,20 +1,17 @@ import { TestBed } from '@angular/core/testing'; + import { Observable, of as observableOf } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; import { cold, hot } from 'jasmine-marbles'; + import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; import { GLOBAL_CONFIG } from '../../../config'; -import { - CommitSSBAction, - EmptySSBAction, - ServerSyncBufferActionTypes -} from './server-sync-buffer.actions'; +import { CommitSSBAction, EmptySSBAction, ServerSyncBufferActionTypes } from './server-sync-buffer.actions'; import { RestRequestMethod } from '../data/rest-request-method'; -import { Store } from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; import { RequestService } from '../data/request.service'; import { ObjectCacheService } from './object-cache.service'; import { MockStore } from '../../shared/testing/mock-store'; -import { ObjectCacheState } from './object-cache.reducer'; import * as operators from 'rxjs/operators'; import { spyOnOperator } from '../../shared/testing/utils'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -38,8 +35,10 @@ describe('ServerSyncBufferEffects', () => { let store; beforeEach(() => { - store = new MockStore({}); TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], providers: [ ServerSyncBufferEffects, provideMockActions(() => actions), @@ -47,18 +46,19 @@ describe('ServerSyncBufferEffects', () => { { provide: RequestService, useValue: getMockRequestService() }, { provide: ObjectCacheService, useValue: { - getBySelfLink: (link) => { + getObjectBySelfLink: (link) => { const object = new DSpaceObject(); object.self = link; return observableOf(object); } } }, - { provide: Store, useValue: store } + { provide: Store, useClass: MockStore } // other providers ], }); + store = TestBed.get(Store); ssbEffects = TestBed.get(ServerSyncBufferEffects); }); diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 0d7392e555..3aa6ad312f 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,6 +1,7 @@ import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; +import { coreSelector } from '../core.selectors'; import { AddToSSBAction, CommitSSBAction, @@ -9,7 +10,7 @@ import { } from './server-sync-buffer.actions'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { coreSelector, CoreState } from '../core.reducers'; +import { CoreState } from '../core.reducers'; import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; @@ -95,7 +96,7 @@ export class ServerSyncBufferEffects { * @returns {Observable} ApplyPatchObjectCacheAction to be dispatched */ private applyPatch(href: string): Observable { - const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1)); + const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1)); return patchObject.pipe( map((object) => { diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index 3e3715d186..c86a0d5654 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -26,7 +26,6 @@ export interface ServerSyncBufferState { buffer: ServerSyncBufferEntry[]; } -// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) const initialState: ServerSyncBufferState = { buffer: [] }; /** diff --git a/src/app/core/config/config-data.ts b/src/app/core/config/config-data.ts index efcdb7eed4..cb40514e45 100644 --- a/src/app/core/config/config-data.ts +++ b/src/app/core/config/config-data.ts @@ -1,5 +1,5 @@ import { PageInfo } from '../shared/page-info.model'; -import { ConfigObject } from '../shared/config/config.model'; +import { ConfigObject } from './models/config.model'; /** * A class to represent the data retrieved by a configuration service @@ -7,7 +7,7 @@ import { ConfigObject } from '../shared/config/config.model'; export class ConfigData { constructor( public pageInfo: PageInfo, - public payload: ConfigObject[] + public payload: ConfigObject ) { } } diff --git a/src/app/core/data/config-response-parsing.service.spec.ts b/src/app/core/config/config-response-parsing.service.spec.ts similarity index 67% rename from src/app/core/data/config-response-parsing.service.spec.ts rename to src/app/core/config/config-response-parsing.service.spec.ts index a33c5cf5b5..7c69f1bdb3 100644 --- a/src/app/core/data/config-response-parsing.service.spec.ts +++ b/src/app/core/config/config-response-parsing.service.spec.ts @@ -2,13 +2,14 @@ import { ConfigSuccessResponse, ErrorResponse } from '../cache/response.models'; import { ConfigResponseParsingService } from './config-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { ConfigRequest } from './request.models'; +import { ConfigRequest } from '../data/request.models'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; -import { SubmissionDefinitionsModel } from '../shared/config/config-submission-definitions.model'; -import { PaginatedList } from './paginated-list'; +import { PaginatedList } from '../data/paginated-list'; import { PageInfo } from '../shared/page-info.model'; +import { NormalizedSubmissionDefinitionsModel } from './models/normalized-config-submission-definitions.model'; +import { NormalizedSubmissionSectionModel } from './models/normalized-config-submission-section.model'; describe('ConfigResponseParsingService', () => { let service: ConfigResponseParsingService; @@ -119,7 +120,8 @@ describe('ConfigResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; }); @@ -128,7 +130,8 @@ describe('ConfigResponseParsingService', () => { const invalidResponse1 = { payload: {}, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; const invalidResponse2 = { @@ -152,18 +155,25 @@ describe('ConfigResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; const invalidResponse3 = { payload: { _links: { self: { href: 'https://rest.api/config/submissiondefinitions/traditional' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '500' + }, statusCode: 500, statusText: 'Internal Server Error' }; - const pageinfo = Object.assign(new PageInfo(), { elementsPerPage: 4, totalElements: 4, totalPages: 1, currentPage: 1 }); + const pageinfo = Object.assign(new PageInfo(), { + elementsPerPage: 4, + totalElements: 4, + totalPages: 1, + currentPage: 1, + self: 'https://rest.api/config/submissiondefinitions/traditional/sections' + }); const definitions = - Object.assign(new SubmissionDefinitionsModel(), { + Object.assign(new NormalizedSubmissionDefinitionsModel(), { isDefault: true, name: 'traditional', type: 'submissiondefinition', @@ -173,10 +183,65 @@ describe('ConfigResponseParsingService', () => { }, self: 'https://rest.api/config/submissiondefinitions/traditional', sections: new PaginatedList(pageinfo, [ - 'https://rest.api/config/submissionsections/traditionalpageone', - 'https://rest.api/config/submissionsections/traditionalpagetwo', - 'https://rest.api/config/submissionsections/upload', - 'https://rest.api/config/submissionsections/license' + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.describe.stepone', + mandatory: true, + sectionType: 'submission-form', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/traditionalpageone', + config: 'https://rest.api/config/submissionforms/traditionalpageone' + }, + self: 'https://rest.api/config/submissionsections/traditionalpageone', + }), + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.describe.steptwo', + mandatory: true, + sectionType: 'submission-form', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/traditionalpagetwo', + config: 'https://rest.api/config/submissionforms/traditionalpagetwo' + }, + self: 'https://rest.api/config/submissionsections/traditionalpagetwo', + }), + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.upload', + mandatory: false, + sectionType: 'upload', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/upload', + config: 'https://rest.api/config/submissionuploads/upload' + }, + self: 'https://rest.api/config/submissionsections/upload', + }), + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.license', + mandatory: true, + sectionType: 'license', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/license' + }, + self: 'https://rest.api/config/submissionsections/license', + }) ]) }); diff --git a/src/app/core/data/config-response-parsing.service.ts b/src/app/core/config/config-response-parsing.service.ts similarity index 69% rename from src/app/core/data/config-response-parsing.service.ts rename to src/app/core/config/config-response-parsing.service.ts index 50303d0a09..b81dc07624 100644 --- a/src/app/core/data/config-response-parsing.service.ts +++ b/src/app/core/config/config-response-parsing.service.ts @@ -1,15 +1,15 @@ import { Inject, Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; -import { ConfigObjectFactory } from '../shared/config/config-object-factory'; +import { ConfigObjectFactory } from './models/config-object-factory'; -import { ConfigObject } from '../shared/config/config.model'; -import { ConfigType } from '../shared/config/config-type'; -import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ConfigObject } from './models/config.model'; +import { ConfigType } from './models/config-type'; +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -27,14 +27,14 @@ export class ConfigResponseParsingService extends BaseResponseParsingService imp } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '201' || data.statusCode === '200' || data.statusCode === 'OK')) { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 201 || data.statusCode === 200)) { const configDefinition = this.process(data.payload, request.uuid); - return new ConfigSuccessResponse(configDefinition, data.statusCode, this.processPageInfo(data.payload)); + return new ConfigSuccessResponse(configDefinition, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from config endpoint'), - {statusText: data.statusCode} + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index 44cfdee358..87add6b656 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -1,4 +1,4 @@ -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { ConfigService } from './config.service'; diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index c6c2e2e7d2..340a7a97d6 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -6,7 +6,6 @@ import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.mode import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ConfigData } from './config-data'; -import { RequestEntry } from '../data/request.reducer'; import { getResponseFromEntry } from '../shared/operators'; export abstract class ConfigService { diff --git a/src/app/core/config/models/config-access-condition-option.model.ts b/src/app/core/config/models/config-access-condition-option.model.ts new file mode 100644 index 0000000000..46bf1b60ce --- /dev/null +++ b/src/app/core/config/models/config-access-condition-option.model.ts @@ -0,0 +1,40 @@ +/** + * Model class for an Access Condition + */ +export class AccessConditionOption { + + /** + * The name for this Access Condition + */ + name: string; + + /** + * The uuid of the Group this Access Condition applies to + */ + groupUUID: string; + + /** + * The uuid of the Group that contains set of groups this Resource Policy applies to + */ + selectGroupUUID: string; + + /** + * A boolean representing if this Access Condition has a start date + */ + hasStartDate: boolean; + + /** + * A boolean representing if this Access Condition has an end date + */ + hasEndDate: boolean; + + /** + * Maximum value of the start date + */ + maxStartDate: string; + + /** + * Maximum value of the end date + */ + maxEndDate: string; +} diff --git a/src/app/core/config/models/config-object-factory.ts b/src/app/core/config/models/config-object-factory.ts new file mode 100644 index 0000000000..44b2e377c4 --- /dev/null +++ b/src/app/core/config/models/config-object-factory.ts @@ -0,0 +1,36 @@ +import { GenericConstructor } from '../../shared/generic-constructor'; +import { ConfigType } from './config-type'; +import { ConfigObject } from './config.model'; +import { NormalizedSubmissionDefinitionsModel } from './normalized-config-submission-definitions.model'; +import { NormalizedSubmissionFormsModel } from './normalized-config-submission-forms.model'; +import { NormalizedSubmissionSectionModel } from './normalized-config-submission-section.model'; +import { NormalizedSubmissionUploadsModel } from './normalized-config-submission-uploads.model'; + +/** + * Class to return normalized models for config objects + */ +export class ConfigObjectFactory { + public static getConstructor(type): GenericConstructor { + switch (type) { + case ConfigType.SubmissionDefinition: + case ConfigType.SubmissionDefinitions: { + return NormalizedSubmissionDefinitionsModel + } + case ConfigType.SubmissionForm: + case ConfigType.SubmissionForms: { + return NormalizedSubmissionFormsModel + } + case ConfigType.SubmissionSection: + case ConfigType.SubmissionSections: { + return NormalizedSubmissionSectionModel + } + case ConfigType.SubmissionUpload: + case ConfigType.SubmissionUploads: { + return NormalizedSubmissionUploadsModel + } + default: { + return undefined; + } + } + } +} diff --git a/src/app/core/shared/config/config-submission-definitions.model.ts b/src/app/core/config/models/config-submission-definitions.model.ts similarity index 63% rename from src/app/core/shared/config/config-submission-definitions.model.ts rename to src/app/core/config/models/config-submission-definitions.model.ts index 0247f13944..8bbbc90056 100644 --- a/src/app/core/shared/config/config-submission-definitions.model.ts +++ b/src/app/core/config/models/config-submission-definitions.model.ts @@ -1,15 +1,17 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; import { SubmissionSectionModel } from './config-submission-section.model'; import { PaginatedList } from '../../data/paginated-list'; -@inheritSerialization(ConfigObject) export class SubmissionDefinitionsModel extends ConfigObject { - @autoserialize + /** + * A boolean representing if this submission definition is the default or not + */ isDefault: boolean; - @autoserializeAs(SubmissionSectionModel) + /** + * A list of SubmissionSectionModel that are present in this submission definition + */ sections: PaginatedList; } diff --git a/src/app/core/shared/config/config-submission-forms.model.ts b/src/app/core/config/models/config-submission-forms.model.ts similarity index 59% rename from src/app/core/shared/config/config-submission-forms.model.ts rename to src/app/core/config/models/config-submission-forms.model.ts index 98d3bf9ce7..ee0962f0e9 100644 --- a/src/app/core/shared/config/config-submission-forms.model.ts +++ b/src/app/core/config/models/config-submission-forms.model.ts @@ -1,14 +1,20 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; +/** + * An interface that define a form row and its properties. + */ export interface FormRowModel { fields: FormFieldModel[]; } -@inheritSerialization(ConfigObject) +/** + * A model class for a NormalizedObject. + */ export class SubmissionFormsModel extends ConfigObject { - @autoserialize + /** + * An array of [FormRowModel] that are present in this form + */ rows: FormRowModel[]; } diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts new file mode 100644 index 0000000000..377a8869e1 --- /dev/null +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -0,0 +1,34 @@ +import { ConfigObject } from './config.model'; +import { SectionsType } from '../../../submission/sections/sections-type'; + +/** + * An interface that define section visibility and its properties. + */ +export interface SubmissionSectionVisibility { + main: any, + other: any +} + +export class SubmissionSectionModel extends ConfigObject { + + /** + * The header for this section + */ + header: string; + + /** + * A boolean representing if this submission section is the mandatory or not + */ + mandatory: boolean; + + /** + * A string representing the kind of section object + */ + sectionType: SectionsType; + + /** + * The [SubmissionSectionVisibility] object for this section + */ + visibility: SubmissionSectionVisibility + +} diff --git a/src/app/core/config/models/config-submission-uploads.model.ts b/src/app/core/config/models/config-submission-uploads.model.ts new file mode 100644 index 0000000000..8bb9ba7f1e --- /dev/null +++ b/src/app/core/config/models/config-submission-uploads.model.ts @@ -0,0 +1,21 @@ +import { ConfigObject } from './config.model'; +import { AccessConditionOption } from './config-access-condition-option.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; + +export class SubmissionUploadsModel extends ConfigObject { + + /** + * A list of available bitstream access conditions + */ + accessConditionOptions: AccessConditionOption[]; + + /** + * An object representing the configuration describing the bistream metadata form + */ + metadata: SubmissionFormsModel; + + required: boolean; + + maxSize: number; + +} diff --git a/src/app/core/shared/config/config-type.ts b/src/app/core/config/models/config-type.ts similarity index 57% rename from src/app/core/shared/config/config-type.ts rename to src/app/core/config/models/config-type.ts index 17ed099229..91371f10f5 100644 --- a/src/app/core/shared/config/config-type.ts +++ b/src/app/core/config/models/config-type.ts @@ -1,9 +1,3 @@ -/** - * TODO replace with actual string enum after upgrade to TypeScript 2.4: - * https://github.com/Microsoft/TypeScript/pull/15486 - */ -import { ResourceType } from '../resource-type'; - export enum ConfigType { SubmissionDefinitions = 'submissiondefinitions', SubmissionDefinition = 'submissiondefinition', @@ -11,5 +5,6 @@ export enum ConfigType { SubmissionForms = 'submissionforms', SubmissionSections = 'submissionsections', SubmissionSection = 'submissionsection', - Authority = 'authority' + SubmissionUploads = 'submissionuploads', + SubmissionUpload = 'submissionupload', } diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts new file mode 100644 index 0000000000..81f20a0b3c --- /dev/null +++ b/src/app/core/config/models/config.model.ts @@ -0,0 +1,27 @@ +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../../shared/resource-type'; + +export abstract class ConfigObject implements CacheableObject { + + /** + * The name for this configuration + */ + public name: string; + + /** + * A string representing the kind of config object + */ + public type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + public _links: { + [name: string]: string + }; + + /** + * The link to the rest endpoint where this config object can be found + */ + self: string; +} diff --git a/src/app/core/config/models/normalized-config-submission-definitions.model.ts b/src/app/core/config/models/normalized-config-submission-definitions.model.ts new file mode 100644 index 0000000000..3887c566c1 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-definitions.model.ts @@ -0,0 +1,25 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { SubmissionSectionModel } from './config-submission-section.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; + +/** + * Normalized class for the configuration describing the submission + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionDefinitionsModel extends NormalizedConfigObject { + + /** + * A boolean representing if this submission definition is the default or not + */ + @autoserialize + isDefault: boolean; + + /** + * A list of SubmissionSectionModel that are present in this submission definition + */ + @autoserializeAs(SubmissionSectionModel) + sections: PaginatedList; + +} diff --git a/src/app/core/config/models/normalized-config-submission-forms.model.ts b/src/app/core/config/models/normalized-config-submission-forms.model.ts new file mode 100644 index 0000000000..a957e8c7fa --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-forms.model.ts @@ -0,0 +1,16 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { FormRowModel, SubmissionFormsModel } from './config-submission-forms.model'; + +/** + * Normalized class for the configuration describing the submission form + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionFormsModel extends NormalizedConfigObject { + + /** + * An array of [FormRowModel] that are present in this form + */ + @autoserialize + rows: FormRowModel[]; +} diff --git a/src/app/core/config/models/normalized-config-submission-section.model.ts b/src/app/core/config/models/normalized-config-submission-section.model.ts new file mode 100644 index 0000000000..c876acf607 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-section.model.ts @@ -0,0 +1,37 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { SectionsType } from '../../../submission/sections/sections-type'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; +import { SubmissionSectionVisibility } from './config-submission-section.model'; + +/** + * Normalized class for the configuration describing the submission section + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionSectionModel extends NormalizedConfigObject { + + /** + * The header for this section + */ + @autoserialize + header: string; + + /** + * A boolean representing if this submission section is the mandatory or not + */ + @autoserialize + mandatory: boolean; + + /** + * A string representing the kind of section object + */ + @autoserialize + sectionType: SectionsType; + + /** + * The [SubmissionSectionVisibility] object for this section + */ + @autoserialize + visibility: SubmissionSectionVisibility + +} diff --git a/src/app/core/config/models/normalized-config-submission-uploads.model.ts b/src/app/core/config/models/normalized-config-submission-uploads.model.ts new file mode 100644 index 0000000000..e49171d6a7 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-uploads.model.ts @@ -0,0 +1,31 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { AccessConditionOption } from './config-access-condition-option.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionUploadsModel } from './config-submission-uploads.model'; + +/** + * Normalized class for the configuration describing the submission upload section + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionUploadsModel extends NormalizedConfigObject { + + /** + * A list of available bitstream access conditions + */ + @autoserialize + accessConditionOptions: AccessConditionOption[]; + + /** + * An object representing the configuration describing the bistream metadata form + */ + @autoserializeAs(SubmissionFormsModel) + metadata: SubmissionFormsModel; + + @autoserialize + required: boolean; + + @autoserialize + maxSize: number; + +} diff --git a/src/app/core/config/models/normalized-config.model.ts b/src/app/core/config/models/normalized-config.model.ts new file mode 100644 index 0000000000..0b75158588 --- /dev/null +++ b/src/app/core/config/models/normalized-config.model.ts @@ -0,0 +1,38 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { NormalizedObject } from '../../cache/models/normalized-object.model'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * Normalized abstract class for a configuration object + */ +@inheritSerialization(NormalizedObject) +export abstract class NormalizedConfigObject implements CacheableObject { + + /** + * The name for this configuration + */ + @autoserialize + public name: string; + + /** + * A string representing the kind of config object + */ + @autoserialize + public type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @autoserialize + public _links: { + [name: string]: string + }; + + /** + * The link to the rest endpoint where this config object can be found + */ + @autoserialize + self: string; + +} diff --git a/src/app/core/config/submission-uploads-config.service.ts b/src/app/core/config/submission-uploads-config.service.ts new file mode 100644 index 0000000000..2e092fa4f3 --- /dev/null +++ b/src/app/core/config/submission-uploads-config.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { ConfigService } from './config.service'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; + +/** + * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. + */ +@Injectable() +export class SubmissionUploadsConfigService extends ConfigService { + protected linkPath = 'submissionuploads'; + protected browseEndpoint = ''; + + constructor( + protected objectCache: ObjectCacheService, + protected requestService: RequestService, + protected halService: HALEndpointService) { + super(); + } +} diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index c9a352c545..bb25c49a7a 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -3,12 +3,16 @@ import { ObjectCacheEffects } from './cache/object-cache.effects'; import { UUIDIndexEffects } from './index/index.effects'; import { RequestEffects } from './data/request.effects'; import { AuthEffects } from './auth/auth.effects'; +import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; +import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; export const coreEffects = [ RequestEffects, ObjectCacheEffects, UUIDIndexEffects, AuthEffects, - ServerSyncBufferEffects + JsonPatchOperationsEffects, + ServerSyncBufferEffects, + ObjectUpdatesEffects ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index c8f4515710..f6b1cf782d 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,8 +1,8 @@ import { + ModuleWithProviders, NgModule, Optional, - SkipSelf, - ModuleWithProviders + SkipSelf } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -24,7 +24,9 @@ import { DSOResponseParsingService } from './data/dso-response-parsing.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; import { FormBuilderService } from '../shared/form/builder/form-builder.service'; +import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service'; import { FormService } from '../shared/form/form.service'; +import { GroupEpersonService } from './eperson/group-eperson.service'; import { HostWindowService } from '../shared/host-window.service'; import { ItemDataService } from './data/item-data.service'; import { MetadataService } from './metadata/metadata.service'; @@ -37,13 +39,17 @@ import { ServerResponseService } from '../shared/services/server-response.servic import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service'; import { BrowseService } from './browse/browse.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; -import { ConfigResponseParsingService } from './data/config-response-parsing.service'; +import { ConfigResponseParsingService } from './config/config-response-parsing.service'; import { RouteService } from '../shared/services/route.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; +import { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; +import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; +import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { AuthorityService } from './integration/authority.service'; import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; +import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; import { UUIDService } from './shared/uuid.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthRequestService } from './auth/auth-request.service'; @@ -56,15 +62,31 @@ import { FacetValueMapResponseParsingService } from './data/facet-value-map-resp import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; import { RegistryService } from './registry/registry.service'; import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service'; -import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; +import { WorkflowitemDataService } from './submission/workflowitem-data.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { UploaderService } from '../shared/uploader/uploader.service'; +import { FileService } from './shared/file.service'; +import { SubmissionRestService } from './submission/submission-rest.service'; import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service'; +import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; +import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { MenuService } from '../shared/menu/menu.service'; +import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; +import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; +import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; +import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; +import { SearchService } from '../+search-page/search-service/search.service'; +import { RoleService } from './roles/role.service'; +import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; +import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; +import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; +import { PoolTaskDataService } from './tasks/pool-task-data.service'; +import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; import { MappingCollectionsReponseParsingService } from './data/mapping-collections-reponse-parsing.service'; import { ObjectSelectService } from '../shared/object-select/object-select.service'; @@ -95,7 +117,10 @@ const PROVIDERS = [ DynamicFormService, DynamicFormValidationService, FormBuilderService, + SectionFormOperationsService, FormService, + EpersonResponseParsingService, + GroupEpersonService, HALEndpointService, HostWindowService, ItemDataService, @@ -103,6 +128,7 @@ const PROVIDERS = [ ObjectCacheService, PaginationComponentOptions, RegistryService, + NormalizedObjectBuildService, RemoteDataBuildService, RequestService, EndpointMapResponseParsingService, @@ -113,9 +139,9 @@ const PROVIDERS = [ RegistryMetadatafieldsResponseParsingService, RegistryBitstreamformatsResponseParsingService, MappingCollectionsReponseParsingService, - MetadataschemaParsingService, DebugResponseParsingService, SearchResponseParsingService, + MyDSpaceResponseParsingService, ServerResponseService, BrowseResponseParsingService, BrowseEntriesResponseParsingService, @@ -125,15 +151,34 @@ const PROVIDERS = [ RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, + SubmissionRestService, SubmissionSectionsConfigService, + SubmissionResponseParsingService, + SubmissionJsonPatchOperationsService, + JsonPatchOperationsBuilder, AuthorityService, IntegrationResponseParsingService, + MetadataschemaParsingService, UploaderService, UUIDService, + NotificationsService, + WorkspaceitemDataService, + WorkflowitemDataService, + UploaderService, + FileService, DSpaceObjectDataService, + DSOChangeAnalyzer, + DefaultChangeAnalyzer, ObjectSelectService, CSSVariableService, MenuService, + ObjectUpdatesService, + SearchService, + MyDSpaceGuard, + RoleService, + TaskResponseParsingService, + ClaimedTaskDataService, + PoolTaskDataService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -141,6 +186,7 @@ const PROVIDERS = [ multi: true }, NotificationsService, + FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 1843e10671..c93b4bf44b 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,25 +1,35 @@ -import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; +import { + ActionReducerMap, + createFeatureSelector, +} from '@ngrx/store'; import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; -import { indexReducer, IndexState } from './index/index.reducer'; +import { indexReducer, MetaIndexState } from './index/index.reducer'; import { requestReducer, RequestState } from './data/request.reducer'; import { authReducer, AuthState } from './auth/auth.reducer'; +import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; +import { + objectUpdatesReducer, + ObjectUpdatesState +} from './data/object-updates/object-updates.reducer'; export interface CoreState { 'cache/object': ObjectCacheState, 'cache/syncbuffer': ServerSyncBufferState, + 'cache/object-updates': ObjectUpdatesState 'data/request': RequestState, - 'index': IndexState, + 'index': MetaIndexState, 'auth': AuthState, + 'json/patch': JsonPatchOperationsState } export const coreReducers: ActionReducerMap = { 'cache/object': objectCacheReducer, 'cache/syncbuffer': serverSyncBufferReducer, + 'cache/object-updates': objectUpdatesReducer, 'data/request': requestReducer, 'index': indexReducer, - 'auth': authReducer + 'auth': authReducer, + 'json/patch': jsonPatchOperationsReducer }; - -export const coreSelector = createFeatureSelector('core'); diff --git a/src/app/core/core.selectors.ts b/src/app/core/core.selectors.ts new file mode 100644 index 0000000000..60365be7c2 --- /dev/null +++ b/src/app/core/core.selectors.ts @@ -0,0 +1,7 @@ +import { createFeatureSelector } from '@ngrx/store'; +import { CoreState } from './core.reducers'; + +/** + * Base selector to select the core state from the store + */ +export const coreSelector = createFeatureSelector('core'); diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index eada156ce9..4ede02778c 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -6,16 +6,7 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList } from './paginated-list'; -import { ResourceType } from '../shared/resource-type'; -import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; - -function isObjectLevel(halObj: any) { - return isNotEmpty(halObj._links) && hasValue(halObj._links.self); -} - -function isPaginatedResponse(halObj: any) { - return hasValue(halObj.page) && hasValue(halObj._embedded); -} +import { isRestDataObject, isRestPaginatedList } from '../cache/builders/normalized-object-build.service'; /* tslint:disable:max-classes-per-file */ @@ -29,12 +20,11 @@ export abstract class BaseResponseParsingService { if (isNotEmpty(data)) { if (hasNoValue(data) || (typeof data !== 'object')) { return data; - } else if (isPaginatedResponse(data)) { + } else if (isRestPaginatedList(data)) { return this.processPaginatedList(data, requestUUID); } else if (Array.isArray(data)) { return this.processArray(data, requestUUID); - } else if (isObjectLevel(data)) { - data = this.fixBadEPersonRestResponse(data); + } else if (isRestDataObject(data)) { const object = this.deserialize(data); if (isNotEmpty(data._embedded)) { Object @@ -43,13 +33,13 @@ export abstract class BaseResponseParsingService { .forEach((property) => { const parsedObj = this.process(data._embedded[property], requestUUID); if (isNotEmpty(parsedObj)) { - if (isPaginatedResponse(data._embedded[property])) { + if (isRestPaginatedList(data._embedded[property])) { object[property] = parsedObj; - object[property].page = parsedObj.page.map((obj) => obj.self); - } else if (isObjectLevel(data._embedded[property])) { - object[property] = parsedObj.self; + object[property].page = parsedObj.page.map((obj) => this.retrieveObjectOrUrl(obj)); + } else if (isRestDataObject(data._embedded[property])) { + object[property] = this.retrieveObjectOrUrl(parsedObj); } else if (Array.isArray(parsedObj)) { - object[property] = parsedObj.map((obj) => obj.self) + object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)) } } }); @@ -63,8 +53,7 @@ export abstract class BaseResponseParsingService { .filter((property) => data.hasOwnProperty(property)) .filter((property) => hasValue(data[property])) .forEach((property) => { - const obj = this.process(data[property], requestUUID); - result[property] = obj; + result[property] = this.process(data[property], requestUUID); }); return result; @@ -76,11 +65,13 @@ export abstract class BaseResponseParsingService { let list = data._embedded; // Workaround for inconsistency in rest response. Issue: https://github.com/DSpace/dspace-angular/issues/238 - if (!Array.isArray(list)) { + if (hasNoValue(list)) { + list = []; + } else if (!Array.isArray(list)) { list = this.flattenSingleKeyObject(list); } const page: ObjectDomain[] = this.processArray(list, requestUUID); - return new PaginatedList(pageInfo, page); + return new PaginatedList(pageInfo, page, ); } protected processArray(data: any, requestUUID: string): ObjectDomain[] { @@ -99,8 +90,7 @@ export abstract class BaseResponseParsingService { if (hasValue(normObjConstructor)) { const serializer = new DSpaceRESTv2Serializer(normObjConstructor); - const res = serializer.deserialize(obj); - return res; + return serializer.deserialize(obj); } else { // TODO: move check to Validator? // throw new Error(`The server returned an object with an unknown a known type: ${type}`); @@ -148,22 +138,11 @@ export abstract class BaseResponseParsingService { return obj[keys[0]]; } - // TODO Remove when https://jira.duraspace.org/browse/DS-4006 is fixed - // See https://github.com/DSpace/dspace-angular/issues/292 - private fixBadEPersonRestResponse(obj: any): any { - if (obj.type === ResourceType.EPerson) { - const groups = obj.groups; - const normGroups = []; - if (isNotEmpty(groups)) { - groups.forEach((group) => { - const parts = ['eperson', 'groups', group.uuid]; - const href = new RESTURLCombiner(this.EnvConfig, ...parts).toString(); - normGroups.push(href); - } - ) - } - return Object.assign({}, obj, { groups: normGroups }); - } - return obj; + protected retrieveObjectOrUrl(obj: any): any { + return this.toCache ? obj.self : obj; + } + + protected isSuccessStatus(statusCode: number) { + return statusCode >= 200 && statusCode < 300; } } diff --git a/src/app/core/data/browse-entries-response-parsing.service.spec.ts b/src/app/core/data/browse-entries-response-parsing.service.spec.ts index a61da7aa95..ef9a833765 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.spec.ts @@ -101,30 +101,17 @@ describe('BrowseEntriesResponseParsingService', () => { number: 0 } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseNotAList = { - payload: { - authority: null, - value: 'Arulmozhiyal, Ramaswamy', - valueLang: null, - count: 1, - type: 'browseEntry', - _links: { - self: { - href: 'https://rest.api/discover/browses/author/entries' - }, - items: { - href: 'https://rest.api/discover/browses/author/items?filterValue=Arulmozhiyal, Ramaswamy' - } - }, - }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseStatusCode = { - payload: {}, statusCode: '500' + payload: {}, statusCode: 500, statusText: 'Internal Server Error' } as DSpaceRESTV2Response; it('should return a GenericSuccessResponse if data contains a valid browse entries response', () => { diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts index 39600b637d..4690d738ed 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -30,16 +30,18 @@ export class BrowseEntriesResponseParsingService extends BaseResponseParsingServ } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) - && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceRESTv2Serializer(BrowseEntry); - const browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - return new GenericSuccessResponse(browseEntries, data.statusCode, this.processPageInfo(data.payload)); + if (isNotEmpty(data.payload)) { + let browseEntries = []; + if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceRESTv2Serializer(BrowseEntry); + browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + } + return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from browse endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/browse-items-response-parsing-service.spec.ts b/src/app/core/data/browse-items-response-parsing-service.spec.ts index 99ea474dc6..50b3be5de7 100644 --- a/src/app/core/data/browse-items-response-parsing-service.spec.ts +++ b/src/app/core/data/browse-items-response-parsing-service.spec.ts @@ -24,13 +24,14 @@ describe('BrowseItemsResponseParsingService', () => { uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', handle: '10986/17472', - metadata: [ - { - key: 'dc.creator', - value: 'World Bank', - language: null - } - ], + metadata: { + 'dc.creator': [ + { + value: 'World Bank', + language: null + } + ] + }, inArchive: true, discoverable: true, withdrawn: false, @@ -56,13 +57,14 @@ describe('BrowseItemsResponseParsingService', () => { uuid: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b', name: 'Development of Local Supply Chain : The Missing Link for Concentrated Solar Power Projects in India', handle: '10986/17475', - metadata: [ - { - key: 'dc.creator', - value: 'World Bank', - language: null - } - ], + metadata: { + 'dc.creator': [ + { + value: 'World Bank', + language: null + } + ] + }, inArchive: true, discoverable: true, withdrawn: false, @@ -106,7 +108,8 @@ describe('BrowseItemsResponseParsingService', () => { number: 0 } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseNotAList = { @@ -115,13 +118,14 @@ describe('BrowseItemsResponseParsingService', () => { uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', handle: '10986/17472', - metadata: [ - { - key: 'dc.creator', - value: 'World Bank', - language: null - } - ], + metadata: { + 'dc.creator': [ + { + value: 'World Bank', + language: null + } + ] + }, inArchive: true, discoverable: true, withdrawn: false, @@ -142,11 +146,12 @@ describe('BrowseItemsResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseStatusCode = { - payload: {}, statusCode: '500' + payload: {}, statusCode: 500, statusText: 'Internal Server Error' } as DSpaceRESTV2Response; it('should return a GenericSuccessResponse if data contains a valid browse items response', () => { diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts index 218c25bac6..fb950f6c68 100644 --- a/src/app/core/data/browse-items-response-parsing-service.ts +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -1,20 +1,17 @@ import { Inject, Injectable } from '@angular/core'; + import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { - ErrorResponse, - GenericSuccessResponse, - RestResponse -} from '../cache/response.models'; +import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; /** * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[]) @@ -42,14 +39,16 @@ export class BrowseItemsResponseParsingService extends BaseResponseParsingServic parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceRESTv2Serializer(DSpaceObject); + const serializer = new DSpaceRESTv2Serializer(NormalizedDSpaceObject); const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - return new GenericSuccessResponse(items, data.statusCode, this.processPageInfo(data.payload)); + return new GenericSuccessResponse(items, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else if (hasValue(data.payload) && hasValue(data.payload.page)) { + return new GenericSuccessResponse([], data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from browse endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index bedf5f03a7..c1b0566e0b 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -55,7 +55,7 @@ describe('BrowseResponseParsingService', () => { }, _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' + }, statusCode: 200, statusText: 'OK' } as DSpaceRESTV2Response; invalidResponse1 = { @@ -78,21 +78,21 @@ describe('BrowseResponseParsingService', () => { }, _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' + }, statusCode: 200, statusText: 'OK' } as DSpaceRESTV2Response; invalidResponse2 = { payload: { _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' + }, statusCode: 200, statusText: 'OK' } as DSpaceRESTV2Response; invalidResponse3 = { payload: { _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '500' + }, statusCode: 500, statusText: 'Internal Server Error' } as DSpaceRESTV2Response; definitions = [ diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index 523fffd565..3c67b2b3eb 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -15,12 +15,12 @@ export class BrowseResponseParsingService implements ResponseParsingService { && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { const serializer = new DSpaceRESTv2Serializer(BrowseDefinition); const browseDefinitions = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - return new GenericSuccessResponse(browseDefinitions, data.statusCode); + return new GenericSuccessResponse(browseDefinitions, data.statusCode, data.statusText); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from browse endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/change-analyzer.ts b/src/app/core/data/change-analyzer.ts new file mode 100644 index 0000000000..6b5a69259b --- /dev/null +++ b/src/app/core/data/change-analyzer.ts @@ -0,0 +1,21 @@ +import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { Operation } from 'fast-json-patch/lib/core'; +import { CacheableObject } from '../cache/object-cache.reducer'; + +/** + * An interface to determine what differs between two + * NormalizedObjects + */ +export interface ChangeAnalyzer { + + /** + * Compare two objects and return their differences as a + * JsonPatch Operation Array + * + * @param {NormalizedObject} object1 + * The first object to compare + * @param {NormalizedObject} object2 + * The second object to compare + */ + diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[]; +} diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 43a66b41bd..e4b8442718 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@angular/core'; + +import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedCollection } from '../cache/models/normalized-collection.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { Collection } from '../shared/collection.model'; @@ -9,41 +11,67 @@ import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { FindAllOptions, GetRequest } from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; -import { distinctUntilChanged, map, take, tap } from 'rxjs/operators'; -import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; -import { GetRequest } from './request.models'; import { configureRequest } from '../shared/operators'; -import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { ResponseParsingService } from './parsing.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; @Injectable() -export class CollectionDataService extends ComColDataService { +export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; + protected forceBypassCache = false; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected cds: CommunityDataService, + protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected objectCache: ObjectCacheService + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer ) { super(); } + /** + * Find whether there is a collection whom user has authorization to submit to + * + * @return boolean + * true if the user has at least one collection to submit to + */ + hasAuthorizedCollection(): Observable { + const searchHref = 'findAuthorized'; + const options = new FindAllOptions(); + options.elementsPerPage = 1; + + return this.searchBy(searchHref, options).pipe( + filter((collections: RemoteData>) => !collections.isResponsePending), + take(1), + map((collections: RemoteData>) => collections.payload.totalElements > 0) + ); + } + /** * Fetches the endpoint used for mapping items to a collection * @param collectionId The id of the collection to map items to */ getMappingItemsEndpoint(collectionId): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getFindByIDHref(endpoint, collectionId)), + map((endpoint: string) => this.getIDHref(endpoint, collectionId)), map((endpoint: string) => `${endpoint}/mappingItems`) ); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 867d559c70..7f628fc5b9 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -14,23 +14,34 @@ import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestEntry } from './request.reducer'; import { of as observableOf } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { Item } from '../shared/item.model'; +import { Community } from '../shared/community.model'; const LINK_NAME = 'test'; /* tslint:disable:max-classes-per-file */ -class NormalizedTestObject extends NormalizedObject { +class NormalizedTestObject extends NormalizedObject { } -class TestService extends ComColDataService { +class TestService extends ComColDataService { + protected forceBypassCache = false; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected EnvConfig: GlobalConfig, protected cds: CommunityDataService, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { super(); @@ -45,11 +56,15 @@ describe('ComColDataService', () => { let requestService: RequestService; let cds: CommunityDataService; let objectCache: ObjectCacheService; - const halService: any = {}; + let halService: any = {}; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; const EnvConfig = {} as GlobalConfig; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = {} as NormalizedObjectBuildService; const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const options = Object.assign(new FindAllOptions(), { @@ -65,17 +80,22 @@ describe('ComColDataService', () => { const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; const serviceEndpoint = `https://rest.api/core/${LINK_NAME}`; + const authHeader = 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJlaWQiOiJhNjA4NmIzNC0zOTE4LTQ1YjctOGRkZC05MzI5YTcwMmEyNmEiLCJzZyI6W10sImV4cCI6MTUzNDk0MDcyNX0.RV5GAtiX6cpwBN77P_v16iG9ipeyiO7faNYSNMzq_sQ'; + + const mockHalService = { + getEndpoint: (linkPath) => observableOf(communitiesEndpoint) + }; function initMockCommunityDataService(): CommunityDataService { return jasmine.createSpyObj('responseCache', { getEndpoint: hot('--a-', { a: communitiesEndpoint }), - getFindByIDHref: cold('b-', { b: communityEndpoint }) + getIDHref: cold('b-', { b: communityEndpoint }) }); } function initMockObjectCacheService(): ObjectCacheService { return jasmine.createSpyObj('objectCache', { - getByUUID: cold('d-', { + getObjectByUUID: cold('d-', { d: { _links: { [LINK_NAME]: scopedEndpoint @@ -89,15 +109,27 @@ describe('ComColDataService', () => { return new TestService( requestService, rdbService, + dataBuildService, store, EnvConfig, cds, - halService, objectCache, + halService, + notificationsService, + http, + comparator, LINK_NAME ); } + beforeEach(() => { + cds = initMockCommunityDataService(); + requestService = getMockRequestService(); + objectCache = initMockObjectCacheService(); + halService = mockHalService; + service = initTestService(); + }); + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); @@ -128,7 +160,7 @@ describe('ComColDataService', () => { it('should fetch the scope Community from the cache', () => { scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe()); scheduler.flush(); - expect(objectCache.getByUUID).toHaveBeenCalledWith(scopeID); + expect(objectCache.getObjectByUUID).toHaveBeenCalledWith(scopeID); }); it('should return the endpoint to fetch resources within the given scope', () => { @@ -156,4 +188,5 @@ describe('ComColDataService', () => { }); }); + }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index d3eed88ffd..9d82cc5047 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,27 +1,17 @@ -import { - distinctUntilChanged, - filter, - first, - map, - mergeMap, - share, - take, - tap -} from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, mergeMap, share, take, tap } from 'rxjs/operators'; import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CommunityDataService } from './community-data.service'; import { DataService } from './data.service'; import { FindAllOptions, FindByIDRequest } from './request.models'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestEntry } from './request.reducer'; import { getResponseFromEntry } from '../shared/operators'; +import { CacheableObject } from '../cache/object-cache.reducer'; -export abstract class ComColDataService extends DataService { +export abstract class ComColDataService extends DataService { protected abstract cds: CommunityDataService; protected abstract objectCache: ObjectCacheService; protected abstract halService: HALEndpointService; @@ -41,7 +31,7 @@ export abstract class ComColDataService this.cds.getFindByIDHref(endpoint, options.scopeID)), + mergeMap((endpoint: string) => this.cds.getIDHref(endpoint, options.scopeID)), filter((href: string) => isNotEmpty(href)), take(1), tap((href: string) => { @@ -59,7 +49,7 @@ export abstract class ComColDataService response.isSuccessful), - mergeMap(() => this.objectCache.getByUUID(options.scopeID)), + mergeMap(() => this.objectCache.getObjectByUUID(options.scopeID)), map((nc: NormalizedCommunity) => nc._links[linkPath]), filter((href) => isNotEmpty(href)) ); diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index a037936202..8db4d762eb 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,9 +1,8 @@ -import { filter, mergeMap, take } from 'rxjs/operators'; +import { filter, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; @@ -12,22 +11,31 @@ import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions, FindAllRequest } from './request.models'; import { RemoteData } from './remote-data'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue } from '../../shared/empty.util'; import { Observable } from 'rxjs'; import { PaginatedList } from './paginated-list'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @Injectable() -export class CommunityDataService extends ComColDataService { +export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; protected topLinkPath = 'communities/search/top'; protected cds = this; + protected forceBypassCache = false; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, + protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected objectCache: ObjectCacheService + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer ) { super(); } @@ -46,6 +54,6 @@ export class CommunityDataService extends ComColDataService(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } } diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 7da709abd5..dede6f8ae2 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -5,28 +5,38 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { CoreState } from '../core.reducers'; import { Store } from '@ngrx/store'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { FindAllOptions } from './request.models'; -import { SortOptions, SortDirection } from '../cache/models/sort-options.model'; -import { of as observableOf } from 'rxjs'; +import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { Operation } from '../../../../node_modules/fast-json-patch'; +import { compare, Operation } from 'fast-json-patch'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { ChangeAnalyzer } from './change-analyzer'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { Item } from '../shared/item.model'; const endpoint = 'https://rest.api/core'; // tslint:disable:max-classes-per-file -class NormalizedTestObject extends NormalizedObject { +class NormalizedTestObject extends NormalizedObject { } -class TestService extends DataService { +class TestService extends DataService { + protected forceBypassCache = false; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected linkPath: string, protected halService: HALEndpointService, - protected objectCache: ObjectCacheService + protected objectCache: ObjectCacheService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer ) { super(); } @@ -36,17 +46,29 @@ class TestService extends DataService { } } +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: NormalizedTestObject, object2: NormalizedTestObject): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } + +} describe('DataService', () => { let service: TestService; let options: FindAllOptions; const requestService = {} as RequestService; const halService = {} as HALEndpointService; const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = new DummyChangeAnalyzer() as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; const objectCache = { addPatch: () => { /* empty */ }, - getBySelfLink: () => { + getObjectBySelfLink: () => { /* empty */ } } as any; @@ -56,13 +78,16 @@ describe('DataService', () => { return new TestService( requestService, rdbService, + dataBuildService, store, endpoint, halService, - objectCache + objectCache, + notificationsService, + http, + comparator, ); } - service = initTestService(); describe('getFindAllHref', () => { @@ -120,7 +145,7 @@ describe('DataService', () => { elementsPerPage: 10, sort: sortOptions, startsWith: 'ab' - } + }; const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; @@ -134,7 +159,7 @@ describe('DataService', () => { let selfLink; beforeEach(() => { - operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation]; + operations = [{ op: 'replace', path: '/metadata/dc.title', value: 'random string' } as Operation]; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; spyOn(objectCache, 'addPatch'); }); @@ -153,28 +178,29 @@ describe('DataService', () => { const name1 = 'random string'; const name2 = 'another random string'; beforeEach(() => { - operations = [{ op: 'replace', path: '/name', value: name2 } as Operation]; + operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation]; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; dso = new DSpaceObject(); dso.self = selfLink; - dso.name = name1; + dso.metadata = [{ key: 'dc.title', value: name1 }]; dso2 = new DSpaceObject(); dso2.self = selfLink; - dso2.name = name2; + dso2.metadata = [{ key: 'dc.title', value: name2 }]; - spyOn(objectCache, 'getBySelfLink').and.returnValue(dso); + spyOn(service, 'findById').and.returnValues(observableOf(dso)); + spyOn(objectCache, 'getObjectBySelfLink').and.returnValues(observableOf(dso)); spyOn(objectCache, 'addPatch'); }); it('should call addPatch on the object cache with the right parameters when there are differences', () => { - service.update(dso2); + service.update(dso2).subscribe(); expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); }); it('should not call addPatch on the object cache with the right parameters when there are no differences', () => { - service.update(dso); + service.update(dso).subscribe(); expect(objectCache.addPatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 0921592a83..fc4da69a5c 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,35 +1,109 @@ -import { filter, find, map, take } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; + import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; + +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; -import { FindAllOptions, FindAllRequest, FindByIDRequest, GetRequest } from './request.models'; +import { + CreateRequest, + DeleteByIDRequest, + FindAllOptions, + FindAllRequest, + FindByIDRequest, + GetRequest +} from './request.models'; import { RequestService } from './request.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { compare, Operation } from 'fast-json-patch'; +import { SearchParam } from '../cache/models/search-param.model'; +import { Operation } from 'fast-json-patch'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { RequestEntry } from './request.reducer'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ChangeAnalyzer } from './change-analyzer'; +import { RestRequestMethod } from './rest-request-method'; -export abstract class DataService { +export abstract class DataService { protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; + protected abstract dataBuildService: NormalizedObjectBuildService; protected abstract store: Store; protected abstract linkPath: string; protected abstract halService: HALEndpointService; + protected abstract forceBypassCache = false; protected abstract objectCache: ObjectCacheService; + protected abstract notificationsService: NotificationsService; + protected abstract http: HttpClient; + protected abstract comparator: ChangeAnalyzer; public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable + /** + * Create the HREF with given options object + * + * @param options The [[FindAllOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + */ protected getFindAllHref(options: FindAllOptions = {}, linkPath?: string): Observable { let result: Observable; const args = []; - result = this.getBrowseEndpoint(options, linkPath); + result = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); + + return this.buildHrefFromFindOptions(result, args, options); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindAllOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + */ + protected getSearchByHref(searchMethod: string, options: FindAllOptions = {}): Observable { + let result: Observable; + const args = []; + + result = this.getSearchEndpoint(searchMethod); + + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: SearchParam) => { + args.push(`${param.fieldName}=${param.fieldValue}`); + }) + } + + return this.buildHrefFromFindOptions(result, args, options); + } + + /** + * Turn an options object into a query string and combine it with the given HREF + * + * @param href$ The HREF to which the query string should be appended + * @param args Array with additional params to combine with query string + * @param options The [[FindAllOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + */ + protected buildHrefFromFindOptions(href$: Observable, args: string[], options: FindAllOptions): Observable { + if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ args.push(`page=${options.currentPage - 1}`); @@ -44,47 +118,84 @@ export abstract class DataService args.push(`startsWith=${options.startsWith}`); } if (isNotEmpty(args)) { - return result.pipe(map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString())); + return href$.pipe(map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString())); } else { - return result; + return href$; } } - findAll(options: FindAllOptions = {}): Observable>> { + findAll(options: FindAllOptions = {}): Observable>> { const hrefObs = this.getFindAllHref(options); hrefObs.pipe( - filter((href: string) => hasValue(href)), - take(1)) + first((href: string) => hasValue(href))) .subscribe((href: string) => { const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); - this.requestService.configure(request); + this.requestService.configure(request, this.forceBypassCache); }); - return this.rdbService.buildList(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } - getFindByIDHref(endpoint, resourceID): string { + /** + * Create the HREF for a specific object based on its identifier + * @param endpoint The base endpoint for the type of object + * @param resourceID The identifier for the object + */ + getIDHref(endpoint, resourceID): string { return `${endpoint}/${resourceID}`; } - findById(id: string): Observable> { + findById(id: string): Observable> { const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getFindByIDHref(endpoint, id))); + map((endpoint: string) => this.getIDHref(endpoint, id))); hrefObs.pipe( find((href: string) => hasValue(href))) .subscribe((href: string) => { const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id); - this.requestService.configure(request); + this.requestService.configure(request, this.forceBypassCache); }); - return this.rdbService.buildSingle(hrefObs); + return this.rdbService.buildSingle(hrefObs); } - findByHref(href: string): Observable> { - this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)); - return this.rdbService.buildSingle(href); + findByHref(href: string, options?: HttpOptions): Observable> { + this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href, null, options), this.forceBypassCache); + return this.rdbService.buildSingle(href); + } + + /** + * Return object search endpoint by given search method + * + * @param searchMethod The search method for the object + */ + protected getSearchEndpoint(searchMethod: string): Observable { + return this.halService.getEndpoint(`${this.linkPath}/search`).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => `${href}/${searchMethod}`)); + } + + /** + * Make a new FindAllRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindAllOptions]] object + * @return {Observable>} + * Return an observable that emits response from the server + */ + protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable>> { + + const hrefObs = this.getSearchByHref(searchMethod, options); + + hrefObs.pipe( + first((href: string) => hasValue(href))) + .subscribe((href: string) => { + const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request, true); + }); + + return this.rdbService.buildList(hrefObs) as Observable>>; } /** @@ -101,29 +212,103 @@ export abstract class DataService * The patch is derived from the differences between the given object and its version in the object cache * @param {DSpaceObject} object The given object */ - update(object: DSpaceObject) { - const oldVersion = this.objectCache.getBySelfLink(object.self); - const operations = compare(oldVersion, object); - if (isNotEmpty(operations)) { - this.objectCache.addPatch(object.self, operations); - } + update(object: T): Observable> { + const oldVersion$ = this.objectCache.getObjectBySelfLink(object.self); + return oldVersion$.pipe(take(1), mergeMap((oldVersion: T) => { + const operations = this.comparator.diff(oldVersion, object); + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object.self, operations); + } + return this.findById(object.uuid); + } + )); + + } + + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache + * + * @param {DSpaceObject} dso + * The object to create + * @param {string} parentUUID + * The UUID of the parent to create the new object under + */ + create(dso: T, parentUUID: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpoint: string) => parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint) + ); + + const normalizedObject: NormalizedObject = this.dataBuildService.normalize(dso); + const serializedDso = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(dso.type)).serialize(normalizedObject); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso))) + ); + + // Execute the post request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Resolve self link for new object + const selfLink$ = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful && response instanceof ErrorResponse) { + this.notificationsService.error('Server Error:', response.errorMessage, new NotificationOptions(-1)); + } else { + return response; + } + }), + map((response: any) => { + if (isNotEmpty(response.resourceSelfLinks)) { + return response.resourceSelfLinks[0]; + } + }), + distinctUntilChanged() + ) as Observable; + + return selfLink$.pipe( + switchMap((selfLink: string) => this.findByHref(selfLink)), + ) + } + + /** + * Delete an existing DSpace Object on the server + * @param dso The DSpace Object to be removed + * Return an observable that emits true when the deletion was successful, false when it failed + */ + delete(dso: T): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, dso.uuid))); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new DeleteByIDRequest(requestId, href, dso.uuid); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response.isSuccessful) + ); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod) { + this.requestService.commit(method); } - // TODO implement, after the structure of the REST server's POST response is finalized - // create(dso: DSpaceObject): Observable> { - // const postHrefObs = this.getEndpoint(); - // - // // TODO ID is unknown at this point - // const idHrefObs = postHrefObs.map((href: string) => this.getFindByIDHref(href, dso.id)); - // - // postHrefObs - // .filter((href: string) => hasValue(href)) - // .take(1) - // .subscribe((href: string) => { - // const request = new RestRequest(this.requestService.generateRequestId(), href, RestRequestMethod.POST, dso); - // this.requestService.configure(request); - // }); - // - // return this.rdbService.buildSingle(idHrefObs, this.normalizedResourceType); - // } } diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts new file mode 100644 index 0000000000..cd30479f6d --- /dev/null +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -0,0 +1,27 @@ +import { Operation } from 'fast-json-patch/lib/core'; +import { compare } from 'fast-json-patch'; +import { ChangeAnalyzer } from './change-analyzer'; +import { Injectable } from '@angular/core'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; + +/** + * A class to determine what differs between two + * CacheableObjects + */ +@Injectable() +export class DefaultChangeAnalyzer implements ChangeAnalyzer { + + /** + * Compare the metadata of two CacheableObject and return the differences as + * a JsonPatch Operation Array + * + * @param {NormalizedObject} object1 + * The first object to compare + * @param {NormalizedObject} object2 + * The second object to compare + */ + diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[] { + return compare(object1, object2); + } +} diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts new file mode 100644 index 0000000000..dd3487d3d0 --- /dev/null +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -0,0 +1,27 @@ +import { Operation } from 'fast-json-patch/lib/core'; +import { compare } from 'fast-json-patch'; +import { ChangeAnalyzer } from './change-analyzer'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; +import { Injectable } from '@angular/core'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +/** + * A class to determine what differs between two + * DSpaceObjects + */ +@Injectable() +export class DSOChangeAnalyzer implements ChangeAnalyzer { + + /** + * Compare the metadata of two DSpaceObjects and return the differences as + * a JsonPatch Operation Array + * + * @param {NormalizedDSpaceObject} object1 + * The first object to compare + * @param {NormalizedDSpaceObject} object2 + * The second object to compare + */ + diff(object1: T | NormalizedDSpaceObject, object2: T | NormalizedDSpaceObject): Operation[] { + return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); + } +} diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 1066d11a50..eb95cdae8a 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -13,6 +13,7 @@ import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; @Injectable() export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -28,11 +29,17 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const processRequestDTO = this.process(data.payload, request.uuid); + let processRequestDTO; + // Prevent empty pages returning an error, initialize empty array instead. + if (hasValue(data.payload) && hasValue(data.payload.page) && data.payload.page.totalElements === 0) { + processRequestDTO = { page: [] }; + } else { + processRequestDTO = this.process, ResourceType>(data.payload, request.uuid); + } let objectList = processRequestDTO; if (hasNoValue(processRequestDTO)) { - return new DSOSuccessResponse([], data.statusCode, undefined) + return new DSOSuccessResponse([], data.statusCode, data.statusText, undefined) } if (hasValue(processRequestDTO.page)) { objectList = processRequestDTO.page; @@ -40,7 +47,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem objectList = [processRequestDTO]; } const selfLinks = objectList.map((no) => no.self); - return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload)) + return new DSOSuccessResponse(selfLinks, data.statusCode, data.statusText, this.processPageInfo(data.payload)) } } diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index cdddcb7ce6..a0bba214ae 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -7,6 +7,9 @@ import { FindByIDRequest } from './request.models'; import { RequestService } from './request.service'; import { DSpaceObjectDataService } from './dspace-object-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -40,12 +43,20 @@ describe('DSpaceObjectDataService', () => { }) }); objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = {} as NormalizedObjectBuildService; service = new DSpaceObjectDataService( requestService, rdbService, + dataBuildService, + objectCache, halService, - objectCache + notificationsService, + http, + comparator ) }); @@ -61,7 +72,7 @@ describe('DSpaceObjectDataService', () => { scheduler.schedule(() => service.findById(testObject.uuid)); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestURL, testObject.uuid)); + expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestURL, testObject.uuid), false); }); it('should return a RemoteData for the object with the given ID', () => { diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 324692c676..4f0653f416 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,17 +10,26 @@ import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { FindAllOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; /* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { +class DataServiceImpl extends DataService { protected linkPath = 'dso'; + protected forceBypassCache = false; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, + protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected objectCache: ObjectCacheService) { + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { super(); } @@ -29,7 +37,7 @@ class DataServiceImpl extends DataService return this.halService.getEndpoint(linkPath); } - getFindByIDHref(endpoint, resourceID): string { + getIDHref(endpoint, resourceID): string { return endpoint.replace(/\{\?uuid\}/,`?uuid=${resourceID}`); } } @@ -42,9 +50,13 @@ export class DSpaceObjectDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected objectCache: ObjectCacheService) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, halService, objectCache); + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator); } findById(uuid: string): Observable> { diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index a145477953..080c665ccf 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -20,12 +20,12 @@ export class EndpointMapResponseParsingService implements ResponseParsingService for (const link of Object.keys(links)) { links[link] = links[link].href; } - return new EndpointMapSuccessResponse(links, data.statusCode); + return new EndpointMapSuccessResponse(links, data.statusCode, data.statusText); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from root endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index 02b12dfa10..e65e317642 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -27,6 +27,6 @@ export class FacetConfigResponseParsingService extends BaseResponseParsingServic const config = data.payload._embedded.facets; const serializer = new DSpaceRESTv2Serializer(SearchFilterConfig); const facetConfig = serializer.deserializeArray(config); - return new FacetConfigSuccessResponse(facetConfig, data.statusCode); + return new FacetConfigSuccessResponse(facetConfig, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/facet-value-map-response-parsing.service.ts b/src/app/core/data/facet-value-map-response-parsing.service.ts index 2f580ee952..e03c1a78df 100644 --- a/src/app/core/data/facet-value-map-response-parsing.service.ts +++ b/src/app/core/data/facet-value-map-response-parsing.service.ts @@ -35,10 +35,10 @@ export class FacetValueMapResponseParsingService extends BaseResponseParsingServ payload._embedded.facets.map((facet) => { const values = facet._embedded.values.map((value) => {value.search = value._links.search.href; return value;}); const facetValues = serializer.deserializeArray(values); - const valuesResponse = new FacetValueSuccessResponse(facetValues, data.statusCode, this.processPageInfo(data.payload)); + const valuesResponse = new FacetValueSuccessResponse(facetValues, data.statusCode, data.statusText, this.processPageInfo(data.payload)); facetMap[facet.name] = valuesResponse; }); - return new FacetValueMapSuccessResponse(facetMap, data.statusCode); + return new FacetValueMapSuccessResponse(facetMap, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 54f36a0564..e7665ebed2 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -26,6 +26,6 @@ export class FacetValueResponseParsingService extends BaseResponseParsingService // const values = payload._embedded.values.map((value) => {value.search = value._links.search.href; return value;}); const facetValues = serializer.deserializeArray(payload._embedded.values); - return new FacetValueSuccessResponse(facetValues, data.statusCode, this.processPageInfo(data.payload)); + return new FacetValueSuccessResponse(facetValues, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } } diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts new file mode 100644 index 0000000000..d81ce4b6bd --- /dev/null +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts @@ -0,0 +1,36 @@ +import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; +import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { ResponseParsingService } from './parsing.service'; +import { GetRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; + +describe('FilteredDiscoveryPageResponseParsingService', () => { + let service: FilteredDiscoveryPageResponseParsingService; + + beforeEach(() => { + service = new FilteredDiscoveryPageResponseParsingService(undefined, getMockObjectCacheService()); + }); + + describe('parse', () => { + const request = Object.assign(new GetRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/path'), { + getResponseParser(): GenericConstructor { + return FilteredDiscoveryPageResponseParsingService; + } + }); + + const mockResponse = { + payload: { + 'discovery-query': 'query' + }, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + + it('should return a FilteredDiscoveryQueryResponse containing the correct query', () => { + const response = service.parse(request, mockResponse); + expect((response as FilteredDiscoveryQueryResponse).filterQuery).toBe(mockResponse.payload['discovery-query']); + }) + }); +}); diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts new file mode 100644 index 0000000000..166a915b16 --- /dev/null +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@angular/core'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../config'; +import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models'; + +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a discovery query (string) + * wrapped in a FilteredDiscoveryQueryResponse + */ +@Injectable() +export class FilteredDiscoveryPageResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + objectFactory = {}; + toCache = false; + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { super(); + } + + /** + * Parses data from the REST API to a discovery query wrapped in a FilteredDiscoveryQueryResponse + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const query = data.payload['discovery-query']; + return new FilteredDiscoveryQueryResponse(query, data.statusCode, data.statusText); + } +} diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 1e7817837c..3553a63af4 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -7,32 +7,37 @@ import { CoreState } from '../core.reducers'; import { ItemDataService } from './item-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { FindAllOptions, RestRequest } from './request.models'; -import { Observable, of as observableOf } from 'rxjs'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Observable } from 'rxjs'; import { RestResponse } from '../cache/response.models'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { HttpClient } from '@angular/common/http'; import { RequestEntry } from './request.reducer'; +import { of as observableOf } from 'rxjs'; describe('ItemDataService', () => { let scheduler: TestScheduler; let service: ItemDataService; let bs: BrowseService; - const requestService = Object.assign({ + const requestService = { generateRequestId(): string { return scopeID; }, configure(request: RestRequest) { // Do nothing }, - getByUUID() { - const requestEntry = new RequestEntry(); - requestEntry.response = new RestResponse(true, '200'); - return observableOf(requestEntry); + getByHref(requestHref: string) { + const responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'OK'); + return observableOf(responseCacheEntry); } - }) as RequestService; + } as RequestService; const rdbService = {} as RemoteDataBuildService; - const objectCache = {} as ObjectCacheService; + const store = {} as Store; + const objectCache = {} as ObjectCacheService; const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', {a: itemEndpoint}); @@ -53,12 +58,16 @@ describe('ItemDataService', () => { const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`; const serviceEndpoint = `https://rest.api/core/items`; const browseError = new Error('getBrowseURL failed'); + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = {} as NormalizedObjectBuildService; const itemEndpoint = 'https://rest.api/core/items'; const ScopedItemEndpoint = `https://rest.api/core/items/${scopeID}`; function initMockBrowseService(isSuccessful: boolean) { const obs = isSuccessful ? - cold('--a-', {a: itemBrowseEndpoint}) : + cold('--a-', { a: itemBrowseEndpoint }) : cold('--#-', undefined, browseError); return jasmine.createSpyObj('bs', { getBrowseURLFor: obs @@ -69,10 +78,14 @@ describe('ItemDataService', () => { return new ItemDataService( requestService, rdbService, + dataBuildService, store, bs, + objectCache, halEndpointService, - objectCache + notificationsService, + http, + comparator ); } @@ -86,7 +99,7 @@ describe('ItemDataService', () => { service = initTestService(); const result = service.getBrowseEndpoint(options); - const expected = cold('--b-', {b: scopedEndpoint}); + const expected = cold('--b-', { b: scopedEndpoint }); expect(result).toBeObservable(expected); }); @@ -120,7 +133,7 @@ describe('ItemDataService', () => { }); it('should setWithDrawn', () => { - const expected = new RestResponse(true, '200'); + const expected = new RestResponse(true, 200, 'OK'); const result = service.setWithDrawn(scopeID, true); result.subscribe((v) => expect(v).toEqual(expected)); @@ -142,32 +155,11 @@ describe('ItemDataService', () => { }); it('should setDiscoverable', () => { - const expected = new RestResponse(true, '200'); + const expected = new RestResponse(true, 200, 'OK'); const result = service.setDiscoverable(scopeID, false); result.subscribe((v) => expect(v).toEqual(expected)); }); }); - describe('getItemDeleteEndpoint', () => { - beforeEach(() => { - scheduler = getTestScheduler(); - service = initTestService(); - }); - - it('should return the endpoint to make an item private or public', () => { - const result = service.getItemDeleteEndpoint(scopeID); - const expected = cold('a', {a: ScopedItemEndpoint}); - - expect(result).toBeObservable(expected); - }); - - it('should delete the item', () => { - const expected = new RestResponse(true, '200'); - const result = service.delete(scopeID); - result.subscribe((v) => expect(v).toEqual(expected)); - - }); - }); - }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index c38f9f3d8e..71991da780 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,12 +1,10 @@ - -import { distinctUntilChanged, map, filter, switchMap, tap, take } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedItem } from '../cache/models/normalized-item.model'; import { CoreState } from '../core.reducers'; import { Item } from '../shared/item.model'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -23,24 +21,38 @@ import { RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { + configureRequest, + filterSuccessfulResponses, + getRequestFromRequestHref, + getResponseFromEntry +} from '../shared/operators'; +import { RequestEntry } from './request.reducer'; import { GenericSuccessResponse, RestResponse } from '../cache/response.models'; -import { configureRequest, filterSuccessfulResponses, getResponseFromEntry } from '../shared/operators'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; import { Collection } from '../shared/collection.model'; -import { RequestEntry } from './request.reducer'; @Injectable() -export class ItemDataService extends DataService { +export class ItemDataService extends DataService { protected linkPath = 'items'; + protected forceBypassCache = false; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, private bs: BrowseService, + protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected objectCache: ObjectCacheService) { + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { super(); } @@ -69,7 +81,7 @@ export class ItemDataService extends DataService { */ public getMappingCollectionsEndpoint(itemId: string, collectionId?: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)), + map((endpoint: string) => this.getIDHref(endpoint, itemId)), map((endpoint: string) => `${endpoint}/mappingCollections${collectionId ? `/${collectionId}` : ''}`) ); } @@ -145,7 +157,7 @@ export class ItemDataService extends DataService { */ public getItemWithdrawEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)) + map((endpoint: string) => this.getIDHref(endpoint, itemId)) ); } @@ -155,17 +167,7 @@ export class ItemDataService extends DataService { */ public getItemDiscoverableEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)) - ); - } - - /** - * Get the endpoint to delete the item - * @param itemId - */ - public getItemDeleteEndpoint(itemId: string): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)) + map((endpoint: string) => this.getIDHref(endpoint, itemId)) ); } @@ -184,8 +186,9 @@ export class ItemDataService extends DataService { new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - switchMap((request: RestRequest) => this.requestService.getByUUID(request.uuid)), - getResponseFromEntry() + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) ); } @@ -204,25 +207,9 @@ export class ItemDataService extends DataService { new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - switchMap((request: RestRequest) => this.requestService.getByUUID(request.uuid)), - getResponseFromEntry() + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) ); } - - /** - * Delete the item - * @param itemId - */ - public delete(itemId: string) { - return this.getItemDeleteEndpoint(itemId).pipe( - distinctUntilChanged(), - map((endpointURL: string) => - new DeleteRequest(this.requestService.generateRequestId(), endpointURL) - ), - configureRequest(this.requestService), - switchMap((request: RestRequest) => this.requestService.getByUUID(request.uuid)), - getResponseFromEntry() - ); - } - } diff --git a/src/app/core/data/mapping-collections-reponse-parsing.service.ts b/src/app/core/data/mapping-collections-reponse-parsing.service.ts index 31200be3fb..9272d3d470 100644 --- a/src/app/core/data/mapping-collections-reponse-parsing.service.ts +++ b/src/app/core/data/mapping-collections-reponse-parsing.service.ts @@ -21,12 +21,11 @@ export class MappingCollectionsReponseParsingService implements ResponseParsingS totalPages: 1, currentPage: 1 }), mappingCollections); - return new GenericSuccessResponse(paginatedMappingCollections, data.statusCode); + return new GenericSuccessResponse(paginatedMappingCollections, data.statusCode, data.statusText); } else { return new ErrorResponse( Object.assign( - new Error('Unexpected response from mappingCollections endpoint'), - { statusText: data.statusCode } + new Error('Unexpected response from mappingCollections endpoint'), data ) ); } diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts new file mode 100644 index 0000000000..1d2bf3b221 --- /dev/null +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; + +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { MetadataSchema } from '../metadata/metadataschema.model'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint + */ +@Injectable() +export class MetadataSchemaDataService extends DataService { + protected linkPath = 'metadataschemas'; + protected forceBypassCache = false; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, + protected comparator: DefaultChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected http: HttpClient, + protected notificationsService: NotificationsService) { + super(); + } + + /** + * Get the endpoint for browsing metadataschemas + * @param {FindAllOptions} options + * @returns {Observable} + */ + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + + return null; + } + +} diff --git a/src/app/core/data/metadatafield-parsing.service.ts b/src/app/core/data/metadatafield-parsing.service.ts new file mode 100644 index 0000000000..f9582c394d --- /dev/null +++ b/src/app/core/data/metadatafield-parsing.service.ts @@ -0,0 +1,22 @@ +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { RestRequest } from './request.models'; +import { ResponseParsingService } from './parsing.service'; +import { Injectable } from '@angular/core'; +import { MetadatafieldSuccessResponse, MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models'; +import { MetadataField } from '../metadata/metadatafield.model'; + +/** + * A service responsible for parsing DSpaceRESTV2Response data related to a single MetadataField to a valid RestResponse + */ +@Injectable() +export class MetadatafieldParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + const deserialized = new DSpaceRESTv2Serializer(MetadataField).deserialize(payload); + return new MetadatafieldSuccessResponse(deserialized, data.statusCode, data.statusText); + } + +} diff --git a/src/app/core/data/metadataschema-parsing.service.ts b/src/app/core/data/metadataschema-parsing.service.ts index 78a5257456..f76d6ed2e3 100644 --- a/src/app/core/data/metadataschema-parsing.service.ts +++ b/src/app/core/data/metadataschema-parsing.service.ts @@ -13,7 +13,7 @@ export class MetadataschemaParsingService implements ResponseParsingService { const payload = data.payload; const deserialized = new DSpaceRESTv2Serializer(MetadataSchema).deserialize(payload); - return new MetadataschemaSuccessResponse(deserialized, data.statusCode); + return new MetadataschemaSuccessResponse(deserialized, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts new file mode 100644 index 0000000000..a6945e27b4 --- /dev/null +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; +import { RestResponse, SearchSuccessResponse } from '../cache/response.models'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { hasValue } from '../../shared/empty.util'; +import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; +import { MetadataMap, MetadataValue } from '../shared/metadata.models'; + +@Injectable() +export class MyDSpaceResponseParsingService implements ResponseParsingService { + constructor(private dsoParser: DSOResponseParsingService) { + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + // fallback for unexpected empty response + const emptyPayload = { + _embedded: { + objects: [] + } + }; + const payload = data.payload._embedded.searchResult || emptyPayload; + const hitHighlights: MetadataMap[] = payload._embedded.objects + .map((object) => object.hitHighlights) + .map((hhObject) => { + const mdMap: MetadataMap = {}; + if (hhObject) { + for (const key of Object.keys(hhObject)) { + const value: MetadataValue = Object.assign(new MetadataValue(), { + value: hhObject[key].join('...'), + language: null + }); + mdMap[key] = [value]; + } + } + return mdMap; + }); + + const dsoSelfLinks = payload._embedded.objects + .filter((object) => hasValue(object._embedded)) + .map((object) => object._embedded.indexableObject) + .map((dso) => this.dsoParser.parse(request, { + payload: dso, + statusCode: data.statusCode, + statusText: data.statusText + })) + .map((obj) => obj.resourceSelfLinks) + .reduce((combined, thisElement) => [...combined, ...thisElement], []); + + const objects = payload._embedded.objects + .filter((object) => hasValue(object._embedded)) + .map((object, index) => Object.assign({}, object, { + indexableObject: dsoSelfLinks[index], + hitHighlights: hitHighlights[index], + _embedded: this.filterEmbeddedObjects(object) + })); + payload.objects = objects; + const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload); + return new SearchSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(payload)); + } + + protected filterEmbeddedObjects(object) { + const allowedEmbeddedKeys = ['submitter', 'item', 'workspaceitem', 'workflowitem']; + if (object._embedded.indexableObject && object._embedded.indexableObject._embedded) { + return Object.assign({}, object._embedded, { + indexableObject: Object.assign({}, object._embedded.indexableObject, { + _embedded: Object.keys(object._embedded.indexableObject._embedded) + .filter((key) => allowedEmbeddedKeys.includes(key)) + .reduce((obj, key) => { + obj[key] = object._embedded.indexableObject._embedded[key]; + return obj; + }, {}) + }) + }); + } else { + return object; + } + + } +} diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts new file mode 100644 index 0000000000..6cd74b2626 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -0,0 +1,245 @@ +import { type } from '../../../shared/ngrx/type'; +import { Action } from '@ngrx/store'; +import { Identifiable } from './object-updates.reducer'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +/** + * The list of ObjectUpdatesAction type definitions + */ +export const ObjectUpdatesActionTypes = { + INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), + SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), + SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), + ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), + DISCARD: type('dspace/core/cache/object-updates/DISCARD'), + REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), + REMOVE: type('dspace/core/cache/object-updates/REMOVE'), + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store + */ +export enum FieldChangeType { + UPDATE = 0, + ADD = 1, + REMOVE = 2 +} + +/** + * An ngrx action to initialize a new page's fields in the ObjectUpdates state + */ +export class InitializeFieldsAction implements Action { + type = ObjectUpdatesActionTypes.INITIALIZE_FIELDS; + payload: { + url: string, + fields: Identifiable[], + lastModified: Date + }; + + /** + * Create a new InitializeFieldsAction + * + * @param url + * the unique url of the page for which the fields are being initialized + * @param fields The identifiable fields of which the updates are kept track of + * @param lastModified The last modified date of the object that belongs to the page + */ + constructor( + url: string, + fields: Identifiable[], + lastModified: Date + ) { + this.payload = { url, fields, lastModified }; + } +} + +/** + * An ngrx action to add a new field update in the ObjectUpdates state for a certain page url + */ +export class AddFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.ADD_FIELD; + payload: { + url: string, + field: Identifiable, + changeType: FieldChangeType, + }; + + /** + * Create a new AddFieldUpdateAction + * + * @param url + * the unique url of the page for which a field update is added + * @param field The identifiable field of which a new update is added + * @param changeType The update's change type + */ + constructor( + url: string, + field: Identifiable, + changeType: FieldChangeType) { + this.payload = { url, field, changeType }; + } +} + +/** + * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url + */ +export class SetEditableFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.SET_EDITABLE_FIELD; + payload: { + url: string, + uuid: string, + editable: boolean, + }; + + /** + * Create a new SetEditableFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param editable The new editable value for the field + */ + constructor( + url: string, + fieldUUID: string, + editable: boolean) { + this.payload = { url, uuid: fieldUUID, editable }; + } +} + +/** + * An ngrx action to set the isValid state of an existing field in the ObjectUpdates state for a certain page url + */ +export class SetValidFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.SET_VALID_FIELD; + payload: { + url: string, + uuid: string, + isValid: boolean, + }; + + /** + * Create a new SetValidFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param isValid The new isValid value for the field + */ + constructor( + url: string, + fieldUUID: string, + isValid: boolean) { + this.payload = { url, uuid: fieldUUID, isValid }; + } +} + +/** + * An ngrx action to discard all existing updates in the ObjectUpdates state for a certain page url + */ +export class DiscardObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.DISCARD; + payload: { + url: string, + notification: INotification + }; + + /** + * Create a new DiscardObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be discarded + * @param notification The notification that is raised when changes are discarded + */ + constructor( + url: string, + notification: INotification + ) { + this.payload = { url, notification }; + } +} + +/** + * An ngrx action to reinstate all previously discarded updates in the ObjectUpdates state for a certain page url + */ +export class ReinstateObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REINSTATE; + payload: { + url: string + }; + + /** + * Create a new ReinstateObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be reinstated + */ + constructor( + url: string + ) { + this.payload = { url }; + } +} + +/** + * An ngrx action to remove all previously discarded updates in the ObjectUpdates state for a certain page url + */ +export class RemoveObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE; + payload: { + url: string + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be removed + */ + constructor( + url: string + ) { + this.payload = { url }; + } +} + +/** + * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid + */ +export class RemoveFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE_FIELD; + payload: { + url: string, + uuid: string + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which a field's change should be removed + * @param uuid The UUID of the field for which the change should be removed + */ + constructor( + url: string, + uuid: string + ) { + this.payload = { url, uuid }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all ObjectUpdatesActions + */ +export type ObjectUpdatesAction + = AddFieldUpdateAction + | InitializeFieldsAction + | DiscardObjectUpdatesAction + | ReinstateObjectUpdatesAction + | RemoveObjectUpdatesAction + | RemoveFieldUpdateAction; diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts new file mode 100644 index 0000000000..3f798e61a2 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -0,0 +1,125 @@ +import { async, TestBed } from '@angular/core/testing'; +import { Observable, Subject } from 'rxjs'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { cold, hot } from 'jasmine-marbles'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectUpdatesEffects } from './object-updates.effects'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + RemoveObjectUpdatesAction +} from './object-updates.actions'; +import { + INotification, + Notification +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { filter } from 'rxjs/operators'; +import { hasValue } from '../../../shared/empty.util'; + +describe('ObjectUpdatesEffects', () => { + let updatesEffects: ObjectUpdatesEffects; + let actions: Observable; + let testURL = 'www.dspace.org/dspace7'; + let testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + const fakeID = 'id'; + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + ObjectUpdatesEffects, + provideMockActions(() => actions), + { + provide: NotificationsService, + useValue: { + remove: (notification) => { /* empty */ + } + } + }, + ], + }); + })); + + beforeEach(() => { + testURL = 'www.dspace.org/dspace7'; + testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + updatesEffects = TestBed.get(ObjectUpdatesEffects); + (updatesEffects as any).actionMap$[testURL] = new Subject(); + (updatesEffects as any).notificationActionMap$[fakeID] = new Subject(); + (updatesEffects as any).notificationActionMap$[(updatesEffects as any).allIdentifier] = new Subject(); + }); + + describe('mapLastActions$', () => { + describe('When any ObjectUpdatesAction is triggered', () => { + let action; + let emittedAction; + beforeEach(() => { + action = new RemoveObjectUpdatesAction(testURL); + }); + it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { + action = new RemoveObjectUpdatesAction(testURL); + actions = hot('--a-', { a: action }); + (updatesEffects as any).actionMap$[testURL].subscribe((act) => emittedAction = act); + const expected = cold('--b-', { b: undefined }); + + expect(updatesEffects.mapLastActions$).toBeObservable(expected); + expect(emittedAction).toBe(action); + }); + }); + }); + + describe('removeAfterDiscardOrReinstateOnUndo$', () => { + describe('When an ObjectUpdatesActionTypes.DISCARD action is triggered', () => { + let infoNotification: INotification; + let removeAction; + describe('When there is no user interactions before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 0; + removeAction = new RemoveObjectUpdatesAction(testURL) + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + filter(((action) => hasValue(action)))) + .subscribe((t) => { + expect(t).toEqual(removeAction); + } + ) + ; + }); + }); + + describe('When there a REINSTATE action is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return an action with type NO_ACTION', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { + expect(t).toEqual({ type: 'NO_ACTION' }); + } + ); + }); + }); + + describe('When there any ObjectUpdates action - other than REINSTATE - is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => + expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) + ); + }); + }); + }); + }); +}); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts new file mode 100644 index 0000000000..88cd3bc718 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -0,0 +1,133 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ObjectUpdatesActionTypes, + RemoveObjectUpdatesAction +} from './object-updates.actions'; +import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { of as observableOf, race as observableRace, Subject } from 'rxjs'; +import { hasNoValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { + NotificationsActions, + NotificationsActionTypes, + RemoveNotificationAction +} from '../../../shared/notifications/notifications.actions'; + +/** + * NGRX effects for ObjectUpdatesActions + */ +@Injectable() +export class ObjectUpdatesEffects { + + /** + * Identifier for when an action on all notifications is performed + */ + private allIdentifier = 'all'; + + /** + * Map that keeps track of the latest ObjectUpdatesAction for each page's url + */ + private actionMap$: { + /* Use Subject instead of BehaviorSubject: + we only want Actions that are fired while we're listening + actions that were previously fired do not matter anymore + */ + [url: string]: Subject + } = {}; + + private notificationActionMap$: { + /* Use Subject instead of BehaviorSubject: + we only want Actions that are fired while we're listening + actions that were previously fired do not matter anymore + */ + [id: string]: Subject + } = { all: new Subject() }; + /** + * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key + */ + @Effect({ dispatch: false }) mapLastActions$ = this.actions$ + .pipe( + ofType(...Object.values(ObjectUpdatesActionTypes)), + map((action: ObjectUpdatesAction) => { + const url: string = action.payload.url; + if (hasNoValue(this.actionMap$[url])) { + this.actionMap$[url] = new Subject(); + } + this.actionMap$[url].next(action); + } + ) + ); + + /** + * Effect that makes sure all last fired NotificationActions are stored in the notification map of this service, with the id as their key + */ + @Effect({ dispatch: false }) mapLastNotificationActions$ = this.actions$ + .pipe( + ofType(...Object.values(NotificationsActionTypes)), + map((action: RemoveNotificationAction) => { + const id: string = action.payload.id || action.payload || this.allIdentifier; + if (hasNoValue(this.notificationActionMap$[id])) { + this.notificationActionMap$[id] = new Subject(); + } + this.notificationActionMap$[id].next(action); + } + ) + ); + + /** + * Effect that checks whether the removeAction's notification timeout ends before a user triggers another ObjectUpdatesAction + * When no ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned + * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + */ + @Effect() removeAfterDiscardOrReinstateOnUndo$ = this.actions$ + .pipe( + ofType(ObjectUpdatesActionTypes.DISCARD), + switchMap((action: DiscardObjectUpdatesAction) => { + const url: string = action.payload.url; + const notification: INotification = action.payload.notification; + const timeOut = notification.options.timeOut; + return observableRace( + // Either wait for the delay and perform a remove action + observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)), + // Or wait for a a user action + this.actionMap$[url].pipe( + take(1), + tap(() => { + this.notificationsService.remove(notification); + }), + map((updateAction: ObjectUpdatesAction) => { + if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { + // If someone reinstated, do nothing, just let the reinstating happen + return { type: 'NO_ACTION' } + } + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return new RemoveObjectUpdatesAction(action.payload.url); + }) + ), + this.notificationActionMap$[notification.id].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), + map(() => { + return new RemoveObjectUpdatesAction(action.payload.url); + }) + ), + this.notificationActionMap$[this.allIdentifier].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), + map(() => { + return new RemoveObjectUpdatesAction(action.payload.url); + }) + ) + ) + } + ) + ); + + constructor(private actions$: Actions, + private notificationsService: NotificationsService) { + } + +} diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts new file mode 100644 index 0000000000..f5698b9b78 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -0,0 +1,274 @@ +import * as deepFreeze from 'deep-freeze'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; + +class NullAction extends RemoveFieldUpdateAction { + type = null; + payload = null; + + constructor() { + super(null, null); + } +} + +const identifiable1 = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, John' +}; + +const identifiable1update = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, James' +}; +const identifiable2 = { + uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241', + key: 'dc.title', + language: null, + value: 'New title' +}; +const identifiable3 = { + uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e', + key: 'dc.description.abstract', + language: null, + value: 'Unchanged value' +}; + +const modDate = new Date(2010, 2, 11); +const uuid = identifiable1.uuid; +const url = 'test-object.url/edit'; +describe('objectUpdatesReducer', () => { + const testState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.titl', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + const discardedTestState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + }, + lastModified: modDate + }, + [url + OBJECT_UPDATES_TRASH_PATH]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.titl', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = objectUpdatesReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty object', () => { + const action = new NullAction(); + const initialState = objectUpdatesReducer(undefined, action); + + expect(initialState).toEqual({}); + }); + + it('should perform the INITIALIZE_FIELDS action without affecting the previous state', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { + const action = new SetEditableFieldUpdateAction(url, uuid, false); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the ADD_FIELD action without affecting the previous state', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the DISCARD action without affecting the previous state', () => { + const action = new DiscardObjectUpdatesAction(url, null); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REINSTATE action without affecting the previous state', () => { + const action = new ReinstateObjectUpdatesAction(url); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE_FIELD action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); + + const expectedState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + }, + fieldUpdates: {}, + lastModified: modDate + } + }; + const newState = objectUpdatesReducer(testState, action); + expect(newState).toEqual(expectedState); + }); + + it('should set the given field\'s fieldStates when the SET_EDITABLE_FIELD action is dispatched, based on the payload', () => { + const action = new SetEditableFieldUpdateAction(url, identifiable3.uuid, true); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy(); + }); + + it('should set the given field\'s fieldStates when the SET_VALID_FIELD action is dispatched, based on the payload', () => { + const action = new SetValidFieldUpdateAction(url, identifiable3.uuid, false); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].isValid).toBeFalsy(); + }); + + it('should add a given field\'s update to the state when the ADD_FIELD action is dispatched, based on the payload', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[identifiable1.uuid].field).toEqual(identifiable1update); + expect(newState[url].fieldUpdates[identifiable1.uuid].changeType).toEqual(FieldChangeType.UPDATE); + }); + + it('should discard a given url\'s updates from the state when the DISCARD action is dispatched, based on the payload', () => { + const action = new DiscardObjectUpdatesAction(url, null); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates).toEqual({}); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toEqual(testState[url]); + }); + + it('should reinstate a given url\'s updates from the state when the REINSTATE action is dispatched, based on the payload', () => { + const action = new ReinstateObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState).toEqual(testState); + }); + + it('should remove a given url\'s updates from the state when the REMOVE action is dispatched, based on the payload', () => { + const action = new RemoveObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState[url].fieldUpdates).toBeUndefined(); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); + }); + + it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[uuid]).toBeUndefined(); + }); +}); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts new file mode 100644 index 0000000000..c0f10ff92a --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -0,0 +1,332 @@ +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ObjectUpdatesAction, + ObjectUpdatesActionTypes, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; + +/** + * Path where discarded objects are saved + */ +export const OBJECT_UPDATES_TRASH_PATH = '/trash'; + +/** + * The state for a single field + */ +export interface FieldState { + editable: boolean, + isNew: boolean, + isValid: boolean +} + +/** + * A list of states for all the fields for a single page, mapped by uuid + */ +export interface FieldStates { + [uuid: string]: FieldState; +} + +/** + * Represents every object that has a UUID + */ +export interface Identifiable { + uuid: string +} + +/** + * The state of a single field update + */ +export interface FieldUpdate { + field: Identifiable, + changeType: FieldChangeType +} + +/** + * The states of all field updates available for a single page, mapped by uuid + */ +export interface FieldUpdates { + [uuid: string]: FieldUpdate; +} + +/** + * The updated state of a single page + */ +export interface ObjectUpdatesEntry { + fieldStates: FieldStates; + fieldUpdates: FieldUpdates + lastModified: Date; +} + +/** + * The updated state of all pages, mapped by the page URL + */ +export interface ObjectUpdatesState { + [url: string]: ObjectUpdatesEntry; +} + +/** + * Initial state for an existing initialized field + */ +const initialFieldState = { editable: false, isNew: false, isValid: true }; + +/** + * Initial state for a newly added field + */ +const initialNewFieldState = { editable: true, isNew: true, isValid: undefined }; + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState = Object.create(null); + +/** + * Reducer method to calculate the next ObjectUpdates state, based on the current state and the ObjectUpdatesAction + * @param state The current state + * @param action The action to perform on the current state + */ +export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState { + switch (action.type) { + case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { + return initializeFieldsUpdate(state, action as InitializeFieldsAction); + } + case ObjectUpdatesActionTypes.ADD_FIELD: { + return addFieldUpdate(state, action as AddFieldUpdateAction); + } + case ObjectUpdatesActionTypes.DISCARD: { + return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REINSTATE: { + return reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REMOVE: { + return removeObjectUpdates(state, action as RemoveObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REMOVE_FIELD: { + return removeFieldUpdate(state, action as RemoveFieldUpdateAction); + } + case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: { + return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction); + } + case ObjectUpdatesActionTypes.SET_VALID_FIELD: { + return setValidFieldUpdate(state, action as SetValidFieldUpdateAction); + } + default: { + return state; + } + } +} + +/** + * Initialize the state for a specific url and store all its fields in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { + const url: string = action.payload.url; + const fields: Identifiable[] = action.payload.fields; + const lastModifiedServer: Date = action.payload.lastModified; + const fieldStates = createInitialFieldStates(fields); + const newPageState = Object.assign( + {}, + state[url], + { fieldStates: fieldStates }, + { fieldUpdates: {} }, + { lastModified: lastModifiedServer } + ); + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Add a new update for a specific field to the store + * @param state The current state + * @param action The action to perform on the current state + */ +function addFieldUpdate(state: any, action: AddFieldUpdateAction) { + const url: string = action.payload.url; + const field: Identifiable = action.payload.field; + const changeType: FieldChangeType = action.payload.changeType; + const pageState: ObjectUpdatesEntry = state[url] || {}; + + let states = pageState.fieldStates; + if (changeType === FieldChangeType.ADD) { + states = Object.assign({}, { [field.uuid]: initialNewFieldState }, pageState.fieldStates) + } + + let fieldUpdate: any = pageState.fieldUpdates[field.uuid] || {}; + const newChangeType = determineChangeType(fieldUpdate.changeType, changeType); + + fieldUpdate = Object.assign({}, { field, changeType: newChangeType }); + + const fieldUpdates = Object.assign({}, pageState.fieldUpdates, { [field.uuid]: fieldUpdate }); + + const newPageState = Object.assign({}, pageState, + { fieldStates: states }, + { fieldUpdates: fieldUpdates }); + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Discard all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { + const url: string = action.payload.url; + const pageState: ObjectUpdatesEntry = state[url]; + const newFieldStates = {}; + Object.keys(pageState.fieldStates).forEach((uuid: string) => { + const fieldState: FieldState = pageState.fieldStates[uuid]; + if (!fieldState.isNew) { + /* After discarding we don't want the reset fields to stay editable or invalid */ + newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false, isValid: true }); + } + }); + + const discardedPageState = Object.assign({}, pageState, { + fieldUpdates: {}, + fieldStates: newFieldStates + }); + return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); +} + +/** + * Reinstate all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction) { + const url: string = action.payload.url; + const trashState = state[url + OBJECT_UPDATES_TRASH_PATH]; + + const newState = Object.assign({}, state, { [url]: trashState }); + delete newState[url + OBJECT_UPDATES_TRASH_PATH]; + return newState; +} + +/** + * Remove all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeObjectUpdates(state: any, action: RemoveObjectUpdatesAction) { + const url: string = action.payload.url; + return removeObjectUpdatesByURL(state, url); +} + +/** + * Remove all updates for a specific url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeObjectUpdatesByURL(state: any, url: string) { + const newState = Object.assign({}, state); + delete newState[url + OBJECT_UPDATES_TRASH_PATH]; + return newState; +} + +/** + * Discard the update for a specific action's url and field UUID in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + let newPageState: ObjectUpdatesEntry = state[url]; + if (hasValue(newPageState)) { + const newUpdates: FieldUpdates = Object.assign({}, newPageState.fieldUpdates); + if (hasValue(newUpdates[uuid])) { + delete newUpdates[uuid]; + } + const newFieldStates: FieldStates = Object.assign({}, newPageState.fieldStates); + if (hasValue(newFieldStates[uuid])) { + /* When resetting, make field not editable */ + if (newFieldStates[uuid].isNew) { + /* If this field was added, just throw it away */ + delete newFieldStates[uuid]; + } else { + newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false, isValid: true }); + } + } + newPageState = Object.assign({}, state[url], { + fieldUpdates: newUpdates, + fieldStates: newFieldStates + }); + } + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Determine the most prominent FieldChangeType, ordered as follows: + * undefined < UPDATE < ADD < REMOVE + * @param oldType The current type + * @param newType The new type that should possibly override the new type + */ +function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType): FieldChangeType { + if (hasNoValue(newType)) { + return oldType; + } + if (hasNoValue(oldType)) { + return newType; + } + return oldType.valueOf() > newType.valueOf() ? oldType : newType; +} + +/** + * Set the editable state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ +function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + const editable: boolean = action.payload.editable; + + const pageState: ObjectUpdatesEntry = state[url]; + + const fieldState = pageState.fieldStates[uuid]; + const newFieldState = Object.assign({}, fieldState, { editable }); + + const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState }); + + const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates }); + + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Set the isValid state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ +function setValidFieldUpdate(state: any, action: SetValidFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + const isValid: boolean = action.payload.isValid; + + const pageState: ObjectUpdatesEntry = state[url]; + + const fieldState = pageState.fieldStates[uuid]; + const newFieldState = Object.assign({}, fieldState, { isValid }); + + const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState }); + + const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates }); + + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Method to create an initial FieldStates object based on a list of Identifiable objects + * @param fields Identifiable objects + */ +function createInitialFieldStates(fields: Identifiable[]) { + const uuids = fields.map((field: Identifiable) => field.uuid); + const fieldStates = {}; + uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); + return fieldStates; +} diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts new file mode 100644 index 0000000000..e9fc4652b0 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -0,0 +1,254 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { ObjectUpdatesService } from './object-updates.service'; +import { + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SetEditableFieldUpdateAction +} from './object-updates.actions'; +import { of as observableOf } from 'rxjs'; +import { Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; + +describe('ObjectUpdatesService', () => { + let service: ObjectUpdatesService; + let store: Store; + const value = 'test value'; + const url = 'test-url.com/dspace'; + const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320' }; + const identifiable1Updated = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', value: value }; + const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; + const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; + const identifiables = [identifiable1, identifiable2]; + + const fieldUpdates = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + const modDate = new Date(2010, 2, 11); + + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate + }; + store = new Store(undefined, undefined, undefined); + spyOn(store, 'dispatch'); + service = new ObjectUpdatesService(store); + + spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); + spyOn(service as any, 'getFieldState').and.callFake((uuid) => { + return observableOf(fieldStates[uuid]); + }); + spyOn(service as any, 'saveFieldUpdate'); + }); + + describe('initialize', () => { + it('should dispatch an INITIALIZE action with the correct URL, initial identifiables and the last modified date', () => { + service.initialize(url, identifiables, modDate); + expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate)); + }); + }); + + describe('getFieldUpdates', () => { + it('should return the list of all fields, including their update if there is one', () => { + const result$ = service.getFieldUpdates(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('isEditable', () => { + it('should return false if this identifiable is currently not editable in the store', () => { + const result$ = service.isEditable(url, identifiable1.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently editable in the store', () => { + const result$ = service.isEditable(url, identifiable2.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('isValid', () => { + it('should return false if this identifiable is currently not valid in the store', () => { + const result$ = service.isValid(url, identifiable2.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently valid in the store', () => { + const result$ = service.isValid(url, identifiable1.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('saveAddFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.ADD', () => { + service.saveAddFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.ADD); + }); + }); + + describe('saveRemoveFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.REMOVE', () => { + service.saveRemoveFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.REMOVE); + }); + }); + + describe('saveChangeFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.UPDATE', () => { + service.saveChangeFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.UPDATE); + }); + }); + + describe('setEditableFieldUpdate', () => { + it('should dispatch a SetEditableFieldUpdateAction action with the correct URL, uuid and true when true was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, true); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, true)); + }); + + it('should dispatch an SetEditableFieldUpdateAction action with the correct URL, uuid and false when false was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, false); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, false)); + }); + }); + + describe('discardFieldUpdates', () => { + it('should dispatch a DiscardObjectUpdatesAction action with the correct URL and passed notification ', () => { + const undoNotification = new Notification('id', NotificationType.Info, 'undo'); + service.discardFieldUpdates(url, undoNotification); + expect(store.dispatch).toHaveBeenCalledWith(new DiscardObjectUpdatesAction(url, undoNotification)); + }); + }); + + describe('reinstateFieldUpdates', () => { + it('should dispatch a ReinstateObjectUpdatesAction action with the correct URL ', () => { + service.reinstateFieldUpdates(url); + expect(store.dispatch).toHaveBeenCalledWith(new ReinstateObjectUpdatesAction(url)); + }); + }); + + describe('removeSingleFieldUpdate', () => { + it('should dispatch a RemoveFieldUpdateAction action with the correct URL and uuid', () => { + service.removeSingleFieldUpdate(url, identifiable1.uuid); + expect(store.dispatch).toHaveBeenCalledWith(new RemoveFieldUpdateAction(url, identifiable1.uuid)); + }); + }); + + describe('getUpdatedFields', () => { + it('should return the list of all metadata fields with their new values', () => { + const result$ = service.getUpdatedFields(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = [identifiable1Updated, identifiable2, identifiable3]; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('hasUpdates', () => { + it('should return true when there are updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + describe('when updates are emtpy', () => { + beforeEach(() => { + (service as any).getObjectEntry.and.returnValue(observableOf({})) + }); + + it('should return false when there are no updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('isReinstatable', () => { + + describe('when updates are not emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(true)); + }); + + it('should return true', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('when updates are emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(false)); + }); + + it('should return false', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('getLastModified', () => { + it('should return true when hasUpdates returns true', () => { + const result$ = service.getLastModified(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = modDate; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + +}); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts new file mode 100644 index 0000000000..22d5fd3e77 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -0,0 +1,272 @@ +import { Injectable } from '@angular/core'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { coreSelector } from '../../core.selectors'; +import { + FieldState, + FieldUpdates, + Identifiable, + OBJECT_UPDATES_TRASH_PATH, + ObjectUpdatesEntry, + ObjectUpdatesState +} from './object-updates.reducer'; +import { Observable } from 'rxjs'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + SetEditableFieldUpdateAction, + SetValidFieldUpdateAction +} from './object-updates.actions'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +function objectUpdatesStateSelector(): MemoizedSelector { + return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); +} + +function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector { + return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]); +} + +function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); +} + +/** + * Service that dispatches and reads from the ObjectUpdates' state in the store + */ +@Injectable() +export class ObjectUpdatesService { + constructor(private store: Store) { + + } + + /** + * Method to dispatch an InitializeFieldsAction to the store + * @param url The page's URL for which the changes are being mapped + * @param fields The initial fields for the page's object + * @param lastModified The date the object was last modified + */ + initialize(url, fields: Identifiable[], lastModified: Date): void { + this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); + } + + /** + * Method to dispatch an AddFieldUpdateAction to the store + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + * @param changeType The last type of change applied to this field + */ + private saveFieldUpdate(url: string, field: Identifiable, changeType: FieldChangeType) { + this.store.dispatch(new AddFieldUpdateAction(url, field, changeType)) + } + + /** + * Request the ObjectUpdatesEntry state for a specific URL + * @param url The URL to filter by + */ + private getObjectEntry(url: string): Observable { + return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url))); + } + + /** + * Request the getFieldState state for a specific URL and UUID + * @param url The URL to filter by + * @param uuid The field's UUID to filter by + */ + private getFieldState(url: string, uuid: string): Observable { + return this.store.pipe(select(filterByUrlAndUUIDFieldStateSelector(url, uuid))); + } + + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + */ + getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + let fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (isEmpty(fieldUpdate)) { + const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); + fieldUpdate = { field: identifiable, changeType: undefined }; + } + fieldUpdates[uuid] = fieldUpdate; + }); + return fieldUpdates; + })) + } + + /** + * Method to check if a specific field is currently editable in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ + isEditable(url: string, uuid: string): Observable { + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.editable), + distinctUntilChanged() + ) + } + + /** + * Method to check if a specific field is currently valid in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ + isValid(url: string, uuid: string): Observable { + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.isValid), + distinctUntilChanged() + ) + } + + /** + * Method to check if a specific page is currently valid in the store + * @param url The URL of the page + */ + isValidPage(url: string): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe( + map((entry: ObjectUpdatesEntry) => { + return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0 + }), + distinctUntilChanged() + ) + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.ADD + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveAddFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.ADD); + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.REMOVE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveRemoveFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.REMOVE); + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.UPDATE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveChangeFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); + } + + /** + * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param editable The new value of editable in the store for this field + */ + setEditableFieldUpdate(url: string, uuid: string, editable: boolean) { + this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable)); + } + + /** + * Dispatches a SetValidFieldUpdateAction to the store to set a field's isValid state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param valid The new value of isValid in the store for this field + */ + setValidFieldUpdate(url: string, uuid: string, valid: boolean) { + this.store.dispatch(new SetValidFieldUpdateAction(url, uuid, valid)); + } + + /** + * Method to dispatch an DiscardObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be discarded + * @param undoNotification The notification which is should possibly be canceled + */ + discardFieldUpdates(url: string, undoNotification: INotification) { + this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification)); + } + + /** + * Method to dispatch an ReinstateObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be reinstated + */ + reinstateFieldUpdates(url: string) { + this.store.dispatch(new ReinstateObjectUpdatesAction(url)); + } + + /** + * Method to dispatch an RemoveFieldUpdateAction to the store + * @param url The page's URL for which the changes should be removed + * @param uuid The UUID of the field that should be set + */ + removeSingleFieldUpdate(url: string, uuid) { + this.store.dispatch(new RemoveFieldUpdateAction(url, uuid)); + } + + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a list of updates fields + * @param url The URL of the page for which the updated fields should be requested + * @param initialFields The initial values of the fields + */ + getUpdatedFields(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fields: Identifiable[] = []; + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + const fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (hasNoValue(fieldUpdate) || fieldUpdate.changeType !== FieldChangeType.REMOVE) { + let field; + if (isNotEmpty(fieldUpdate)) { + field = fieldUpdate.field; + } else { + field = initialFields.find((object: Identifiable) => object.uuid === uuid); + } + fields.push(field); + } + }); + return fields; + })) + } + + /** + * Checks if the page currently has updates in the store or not + * @param url The page's url to check for in the store + */ + hasUpdates(url: string): Observable { + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); + } + + /** + * Checks if the page currently is reinstatable in the store or not + * @param url The page's url to check for in the store + */ + isReinstatable(url: string): Observable { + return this.hasUpdates(url + OBJECT_UPDATES_TRASH_PATH) + } + + /** + * Request the current lastModified date stored for the updates in the store + * @param url The page's url to check for in the store + */ + getLastModified(url: string): Observable { + return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); + } +} diff --git a/src/app/core/data/paginated-list.ts b/src/app/core/data/paginated-list.ts index 07d53739d0..e1c1b22569 100644 --- a/src/app/core/data/paginated-list.ts +++ b/src/app/core/data/paginated-list.ts @@ -11,7 +11,7 @@ export class PaginatedList { if (hasValue(this.pageInfo) && hasValue(this.pageInfo.elementsPerPage)) { return this.pageInfo.elementsPerPage; } - return this.page.length; + return this.getPageLength(); } set elementsPerPage(value: number) { @@ -22,7 +22,7 @@ export class PaginatedList { if (hasValue(this.pageInfo) && hasValue(this.pageInfo.totalElements)) { return this.pageInfo.totalElements; } - return this.page.length; + return this.getPageLength(); } set totalElements(value: number) { @@ -81,4 +81,16 @@ export class PaginatedList { set last(last: string) { this.pageInfo.last = last; } + + get self(): string { + return this.pageInfo.self; + } + + set self(self: string) { + this.pageInfo.self = self; + } + + protected getPageLength() { + return (Array.isArray(this.page)) ? this.page.length : 0; + } } diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts new file mode 100644 index 0000000000..6cc031f3c9 --- /dev/null +++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts @@ -0,0 +1,41 @@ +import { PageInfo } from '../shared/page-info.model'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { + RegistryBitstreamformatsSuccessResponse +} from '../cache/response.models'; +import { RegistryBitstreamformatsResponseParsingService } from './registry-bitstreamformats-response-parsing.service'; + +describe('RegistryBitstreamformatsResponseParsingService', () => { + let service: RegistryBitstreamformatsResponseParsingService; + + const mockDSOParser = Object.assign({ + processPageInfo: () => new PageInfo() + }) as DSOResponseParsingService; + + const data = Object.assign({ + payload: { + _embedded: { + bitstreamformats: [ + { + uuid: 'uuid-1', + description: 'a description' + }, + { + uuid: 'uuid-2', + description: 'another description' + }, + ] + } + } + }) as DSpaceRESTV2Response; + + beforeEach(() => { + service = new RegistryBitstreamformatsResponseParsingService(mockDSOParser); + }); + + it('should parse the data correctly', () => { + const response = service.parse(null, data); + expect(response.constructor).toBe(RegistryBitstreamformatsSuccessResponse); + }); +}); diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts index 2ee3bbf75e..899fee4d1e 100644 --- a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts +++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts @@ -19,7 +19,7 @@ export class RegistryBitstreamformatsResponseParsingService implements ResponseP payload.bitstreamformats = bitstreamformats; const deserialized = new DSpaceRESTv2Serializer(RegistryBitstreamformatsResponse).deserialize(payload); - return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload.page)); + return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload.page)); } } diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts new file mode 100644 index 0000000000..5ede21954a --- /dev/null +++ b/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts @@ -0,0 +1,68 @@ +import { PageInfo } from '../shared/page-info.model'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { + RegistryMetadatafieldsSuccessResponse +} from '../cache/response.models'; +import { RegistryMetadatafieldsResponseParsingService } from './registry-metadatafields-response-parsing.service'; + +describe('RegistryMetadatafieldsResponseParsingService', () => { + let service: RegistryMetadatafieldsResponseParsingService; + + const mockDSOParser = Object.assign({ + processPageInfo: () => new PageInfo() + }) as DSOResponseParsingService; + + const data = Object.assign({ + payload: { + _embedded: { + metadatafields: [ + { + id: 1, + element: 'element', + qualifier: 'qualifier', + scopeNote: 'a scope note', + _embedded: { + schema: { + id: 1, + prefix: 'test', + namespace: 'test namespace' + } + } + }, + { + id: 2, + element: 'secondelement', + qualifier: 'secondqualifier', + scopeNote: 'a second scope note', + _embedded: { + schema: { + id: 1, + prefix: 'test', + namespace: 'test namespace' + } + } + }, + ] + } + } + }) as DSpaceRESTV2Response; + + const emptyData = Object.assign({ + payload: {} + }) as DSpaceRESTV2Response; + + beforeEach(() => { + service = new RegistryMetadatafieldsResponseParsingService(mockDSOParser); + }); + + it('should parse the data correctly', () => { + const response = service.parse(null, data); + expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse); + }); + + it('should not produce an error and parse the data correctly when the data is empty', () => { + const response = service.parse(null, emptyData); + expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse); + }); +}); diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts index 0b0982d048..a4bed3240e 100644 --- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts +++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts @@ -9,6 +9,7 @@ import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.seriali import { DSOResponseParsingService } from './dso-response-parsing.service'; import { Injectable } from '@angular/core'; import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; +import { hasValue } from '../../shared/empty.util'; @Injectable() export class RegistryMetadatafieldsResponseParsingService implements ResponseParsingService { @@ -18,15 +19,19 @@ export class RegistryMetadatafieldsResponseParsingService implements ResponsePar parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const payload = data.payload; - const metadatafields = payload._embedded.metadatafields; - metadatafields.forEach((field) => { - field.schema = field._embedded.schema; - }); + let metadatafields = []; + + if (hasValue(payload._embedded)) { + metadatafields = payload._embedded.metadatafields; + metadatafields.forEach((field) => { + field.schema = field._embedded.schema; + }); + } payload.metadatafields = metadatafields; const deserialized = new DSpaceRESTv2Serializer(RegistryMetadatafieldsResponse).deserialize(payload); - return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload.page)); + return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); } } diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts new file mode 100644 index 0000000000..e49305d06a --- /dev/null +++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts @@ -0,0 +1,50 @@ +import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service'; +import { PageInfo } from '../shared/page-info.model'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { RegistryMetadataschemasSuccessResponse } from '../cache/response.models'; + +describe('RegistryMetadataschemasResponseParsingService', () => { + let service: RegistryMetadataschemasResponseParsingService; + + const mockDSOParser = Object.assign({ + processPageInfo: () => new PageInfo() + }) as DSOResponseParsingService; + + const data = Object.assign({ + payload: { + _embedded: { + metadataschemas: [ + { + id: 1, + prefix: 'test', + namespace: 'test namespace' + }, + { + id: 2, + prefix: 'second', + namespace: 'second test namespace' + } + ] + } + } + }) as DSpaceRESTV2Response; + + const emptyData = Object.assign({ + payload: {} + }) as DSpaceRESTV2Response; + + beforeEach(() => { + service = new RegistryMetadataschemasResponseParsingService(mockDSOParser); + }); + + it('should parse the data correctly', () => { + const response = service.parse(null, data); + expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse); + }); + + it('should not produce an error and parse the data correctly when the data is empty', () => { + const response = service.parse(null, emptyData); + expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse); + }); +}); diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts index a70c985b15..d19b334131 100644 --- a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts +++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts @@ -6,6 +6,7 @@ import { RegistryMetadataschemasResponse } from '../registry/registry-metadatasc import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { Injectable } from '@angular/core'; +import { hasValue } from '../../shared/empty.util'; @Injectable() export class RegistryMetadataschemasResponseParsingService implements ResponseParsingService { @@ -15,11 +16,14 @@ export class RegistryMetadataschemasResponseParsingService implements ResponsePa parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const payload = data.payload; - const metadataschemas = payload._embedded.metadataschemas; + let metadataschemas = []; + if (hasValue(payload._embedded)) { + metadataschemas = payload._embedded.metadataschemas; + } payload.metadataschemas = metadataschemas; const deserialized = new DSpaceRESTv2Serializer(RegistryMetadataschemasResponse).deserialize(payload); - return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload.page)); + return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); } } diff --git a/src/app/core/data/remote-data-error.ts b/src/app/core/data/remote-data-error.ts index a2ff27a073..9291bc5447 100644 --- a/src/app/core/data/remote-data-error.ts +++ b/src/app/core/data/remote-data-error.ts @@ -1,6 +1,7 @@ export class RemoteDataError { constructor( - public statusCode: string, + public statusCode: number, + public statusText: string, public message: string ) { } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index dee30bda0c..ac2e17a727 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -5,12 +5,19 @@ import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service'; import { BrowseResponseParsingService } from './browse-response-parsing.service'; -import { ConfigResponseParsingService } from './config-response-parsing.service'; +import { ConfigResponseParsingService } from '../config/config-response-parsing.service'; import { AuthResponseParsingService } from '../auth/auth-response-parsing.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; import { RestRequestMethod } from './rest-request-method'; +import { SearchParam } from '../cache/models/search-param.model'; +import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service'; import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; +import { MetadataschemaParsingService } from './metadataschema-parsing.service'; +import { MetadatafieldParsingService } from './metadatafield-parsing.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; import { MappingCollectionsReponseParsingService } from './mapping-collections-reponse-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -129,6 +136,7 @@ export class FindAllOptions { elementsPerPage?: number; currentPage?: number; sort?: SortOptions; + searchParams?: SearchParam[]; startsWith?: string; } @@ -144,11 +152,11 @@ export class FindAllRequest extends GetRequest { export class EndpointMapRequest extends GetRequest { constructor( - public uuid: string, - public href: string, - public body?: any + uuid: string, + href: string, + body?: any ) { - super(uuid, href, body); + super(uuid, new URLCombiner(href, '?endpointMap').toString(), body); } getResponseParser(): GenericConstructor { @@ -185,8 +193,8 @@ export class MappingCollectionsRequest extends GetRequest { } export class ConfigRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); + constructor(uuid: string, href: string, public options?: HttpOptions) { + super(uuid, href, null, options); } getResponseParser(): GenericConstructor { @@ -224,7 +232,178 @@ export class IntegrationRequest extends GetRequest { } } +/** + * Request to create a MetadataSchema + */ +export class CreateMetadataSchemaRequest extends PostRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return MetadataschemaParsingService; + } +} + +/** + * Request to update a MetadataSchema + */ +export class UpdateMetadataSchemaRequest extends PutRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return MetadataschemaParsingService; + } +} + +/** + * Request to create a MetadataField + */ +export class CreateMetadataFieldRequest extends PostRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return MetadatafieldParsingService; + } +} + +/** + * Request to update a MetadataField + */ +export class UpdateMetadataFieldRequest extends PutRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return MetadatafieldParsingService; + } +} + +/** + * Class representing a submission HTTP GET request object + */ +export class SubmissionRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing a submission HTTP DELETE request object + */ +export class SubmissionDeleteRequest extends DeleteRequest { + constructor(public uuid: string, + public href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing a submission HTTP PATCH request object + */ +export class SubmissionPatchRequest extends PatchRequest { + constructor(public uuid: string, + public href: string, + public body?: any) { + super(uuid, href, body); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing a submission HTTP POST request object + */ +export class SubmissionPostRequest extends PostRequest { + constructor(public uuid: string, + public href: string, + public body?: any, + public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing an eperson HTTP GET request object + */ +export class EpersonRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return EpersonResponseParsingService; + } +} + +export class CreateRequest extends PostRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return DSOResponseParsingService; + } +} + +/** + * Request to delete an object based on its identifier + */ +export class DeleteByIDRequest extends DeleteRequest { + constructor( + uuid: string, + href: string, + public resourceID: string + ) { + super(uuid, href); + } +} + +export class TaskPostRequest extends PostRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return TaskResponseParsingService; + } +} + +export class TaskDeleteRequest extends DeleteRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return TaskResponseParsingService; + } +} + +export class MyDSpaceRequest extends GetRequest { + public responseMsToLive = 0; +} + export class RequestError extends Error { + statusCode: number; statusText: string; } /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/request.reducer.spec.ts b/src/app/core/data/request.reducer.spec.ts index 57fbb01ce1..65a4ddba17 100644 --- a/src/app/core/data/request.reducer.spec.ts +++ b/src/app/core/data/request.reducer.spec.ts @@ -4,12 +4,12 @@ import { requestReducer, RequestState } from './request.reducer'; import { RequestCompleteAction, RequestConfigureAction, - RequestExecuteAction, ResetResponseTimestampsAction + RequestExecuteAction, RequestRemoveAction, ResetResponseTimestampsAction } from './request.actions'; import { GetRequest } from './request.models'; import { RestResponse } from '../cache/response.models'; -const response = new RestResponse(true, 'OK'); +const response = new RestResponse(true, 200, 'OK'); class NullAction extends RequestCompleteAction { type = null; payload = null; @@ -89,8 +89,8 @@ describe('requestReducer', () => { expect(newState[id1].requestPending).toEqual(state[id1].requestPending); expect(newState[id1].responsePending).toEqual(false); expect(newState[id1].completed).toEqual(true); - expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful) - expect(newState[id1].response.statusCode).toEqual(response.statusCode) + expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful); + expect(newState[id1].response.statusCode).toEqual(response.statusCode); expect(newState[id1].response.timeAdded).toBeTruthy() }); @@ -110,4 +110,13 @@ describe('requestReducer', () => { expect(newState[id1].response.statusCode).toEqual(response.statusCode); expect(newState[id1].response.timeAdded).toBe(timeStamp); }); + + it('should remove the correct request, in response to a REMOVE action', () => { + const state = testState; + + const action = new RequestRemoveAction(id1); + const newState = requestReducer(state, action); + + expect(newState[id1]).toBeUndefined(); + }); }); diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts index 322ac46727..e324e4d5a2 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -90,7 +90,17 @@ function completeRequest(state: RequestState, action: RequestCompleteAction): Re }); } -function resetResponseTimestamps(state: RequestState, action: ResetResponseTimestampsAction) { +/** + * Reset the timeAdded property of all responses + * + * @param state + * the current state + * @param action + * a RequestCompleteAction + * @return RequestState + * the new state, with the timeAdded property reset + */ +function resetResponseTimestamps(state: RequestState, action: ResetResponseTimestampsAction): RequestState { const newState = Object.create(null); Object.keys(state).forEach((key) => { newState[key] = Object.assign({}, state[key], @@ -100,6 +110,11 @@ function resetResponseTimestamps(state: RequestState, action: ResetResponseTimes return newState; } +/** + * Remove a request from the RequestState + * @param state The current RequestState + * @param action The RequestRemoveAction to perform + */ function removeRequest(state: RequestState, action: RequestRemoveAction): RequestState { const newState = Object.create(null); for (const value in state) { diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index e150d3c458..e2bc04040f 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,12 +1,14 @@ +import * as ngrx from '@ngrx/store'; +import { ActionsSubject, Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { of as observableOf } from 'rxjs'; +import { BehaviorSubject, EMPTY, of as observableOf } from 'rxjs'; + import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { UUIDService } from '../shared/uuid.service'; import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; -import * as ngrx from '@ngrx/store'; import { DeleteRequest, GetRequest, @@ -18,10 +20,7 @@ import { RestRequest } from './request.models'; import { RequestService } from './request.service'; -import { ActionsSubject, Store } from '@ngrx/store'; import { TestScheduler } from 'rxjs/testing'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; -import { IndexState } from '../index/index.reducer'; describe('RequestService', () => { let scheduler: TestScheduler; @@ -30,7 +29,6 @@ describe('RequestService', () => { let objectCache: ObjectCacheService; let uuidService: UUIDService; let store: Store; - let indexStore: Store; const testUUID = '5f2a0d2a-effa-4d54-bd54-5663b960f9eb'; const testHref = 'https://rest.api/endpoint/selfLink'; @@ -42,6 +40,7 @@ describe('RequestService', () => { const testHeadRequest = new HeadRequest(testUUID, testHref); const testPatchRequest = new PatchRequest(testUUID, testHref); let selectSpy; + beforeEach(() => { scheduler = getTestScheduler(); @@ -50,8 +49,7 @@ describe('RequestService', () => { uuidService = getMockUUIDService(); - store = new Store(undefined, new ActionsSubject(), null); - indexStore = new Store(undefined, new ActionsSubject(), null); + store = new Store(new BehaviorSubject({}), new ActionsSubject(), null); selectSpy = spyOnProperty(ngrx, 'select'); selectSpy.and.callFake(() => { return () => { @@ -63,7 +61,7 @@ describe('RequestService', () => { objectCache, uuidService, store, - indexStore + undefined ); serviceAsAny = service as any; }); @@ -174,9 +172,6 @@ describe('RequestService', () => { it('should return an Observable of undefined', () => { const result = service.getByUUID(testUUID); - // const expected = cold('b', { - // b: undefined - // }); scheduler.expectObservable(result).toBe('b', { b: undefined }); }); @@ -294,29 +289,8 @@ describe('RequestService', () => { service.configure(testPatchRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest); }); - - it('shouldn\'t track it on it\'s way to the store', () => { - spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); - - serviceAsAny.dispatchRequest(testPostRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testPutRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testDeleteRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testOptionsRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testHeadRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testPatchRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - }); }); + }); describe('isCachedOrPending', () => { @@ -324,6 +298,7 @@ describe('RequestService', () => { describe('in the ObjectCache', () => { beforeEach(() => { (objectCache.hasBySelfLink as any).and.returnValue(true); + spyOn(serviceAsAny, 'hasByHref').and.returnValue(false); }); it('should return true', () => { @@ -333,63 +308,16 @@ describe('RequestService', () => { expect(result).toEqual(expected); }); }); - describe('in the responseCache', () => { + describe('in the request cache', () => { beforeEach(() => { - spyOn(serviceAsAny, 'isReusable').and.returnValue(observableOf(true)); - spyOn(serviceAsAny, 'getByHref').and.returnValue(observableOf(undefined)); + (objectCache.hasBySelfLink as any).and.returnValue(false); + spyOn(serviceAsAny, 'hasByHref').and.returnValue(true); }); + it('should return true', () => { + const result = serviceAsAny.isCachedOrPending(testGetRequest); + const expected = true; - describe('and it\'s a DSOSuccessResponse', () => { - beforeEach(() => { - (serviceAsAny.getByHref as any).and.returnValue(observableOf({ - response: { - isSuccessful: true, - resourceSelfLinks: [ - 'https://rest.api/endpoint/selfLink1', - 'https://rest.api/endpoint/selfLink2' - ] - } - } - )); - }); - - it('should return true if all top level links in the response are cached in the object cache', () => { - (objectCache.hasBySelfLink as any).and.returnValues(false, true, true); - - const result = serviceAsAny.isCachedOrPending(testGetRequest); - const expected = true; - - expect(result).toEqual(expected); - }); - it('should return false if not all top level links in the response are cached in the object cache', () => { - (objectCache.hasBySelfLink as any).and.returnValues(false, true, false); - spyOn(service, 'isPending').and.returnValue(false); - - const result = serviceAsAny.isCachedOrPending(testGetRequest); - const expected = false; - - expect(result).toEqual(expected); - }); - }); - - describe('and it isn\'t a DSOSuccessResponse', () => { - beforeEach(() => { - (objectCache.hasBySelfLink as any).and.returnValue(false); - (service as any).isReusable.and.returnValue(observableOf(true)); - (serviceAsAny.getByHref as any).and.returnValue(observableOf({ - response: { - isSuccessful: true - } - } - )); - }); - - it('should return true', () => { - const result = serviceAsAny.isCachedOrPending(testGetRequest); - const expected = true; - - expect(result).toEqual(expected); - }); + expect(result).toEqual(expected); }); }); }); @@ -433,6 +361,30 @@ describe('RequestService', () => { serviceAsAny.dispatchRequest(request); expect(store.dispatch).toHaveBeenCalledWith(new RequestExecuteAction(request.uuid)); }); + + describe('when it\'s not a GET request', () => { + it('shouldn\'t track it', () => { + spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); + + serviceAsAny.dispatchRequest(testPostRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPutRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testDeleteRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testOptionsRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testHeadRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPatchRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + }); + }); }); describe('trackRequestsOnTheirWayToTheStore', () => { @@ -463,104 +415,128 @@ describe('RequestService', () => { }); }); - describe('isReusable', () => { - describe('when the given UUID is has no value', () => { - let reusable; + describe('isValid', () => { + describe('when the given entry has no value', () => { + let valid; beforeEach(() => { - const uuid = undefined; - reusable = serviceAsAny.isReusable(uuid); + const entry = undefined; + valid = serviceAsAny.isValid(entry); }); it('return an observable emitting false', () => { - reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + expect(valid).toBe(false); }) }); - describe('when the given UUID has a value, but no cached entry is found', () => { - let reusable; + describe('when the given entry has a value, but the request is not completed', () => { + let valid; + const requestEntry = { completed: false }; beforeEach(() => { - spyOn(service, 'getByUUID').and.returnValue(observableOf(undefined)); - const uuid = 'a45bb291-1adb-40d9-b2fc-7ad9080607be'; - reusable = serviceAsAny.isReusable(uuid); + spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); }); it('return an observable emitting false', () => { - reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + expect(valid).toBe(false); }) }); - describe('when the given UUID has a value, a cached entry is found, but it has no response', () => { - let reusable; + describe('when the given entry has a value, but the response is not successful', () => { + let valid; + const requestEntry = { completed: true, response: { isSuccessful: false } }; beforeEach(() => { - spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: undefined })); - const uuid = '53c9b814-ad8b-4567-9bc1-d9bb6cfba6c8'; - reusable = serviceAsAny.isReusable(uuid); + spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); }); it('return an observable emitting false', () => { - reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + expect(valid).toBe(false); }) }); - describe('when the given UUID has a value, a cached entry is found, but its response was not successful', () => { - let reusable; - beforeEach(() => { - spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: { isSuccessful: false } })); - const uuid = '694c9b32-7b2e-4788-835b-ef3fc2252e6c'; - reusable = serviceAsAny.isReusable(uuid); - }); - it('return an observable emitting false', () => { - reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); - }) - }); - - describe('when the given UUID has a value, a cached entry is found, its response was successful, but the response is outdated', () => { - let reusable; + describe('when the given UUID has a value, its response was successful, but the response is outdated', () => { + let valid; const now = 100000; const timeAdded = 99899; const msToLive = 100; + const requestEntry = { + completed: true, + response: { + isSuccessful: true, + timeAdded: timeAdded + }, + request: { + responseMsToLive: msToLive, + } + }; beforeEach(() => { spyOn(Date.prototype, 'getTime').and.returnValue(now); - spyOn(service, 'getByUUID').and.returnValue(observableOf({ - response: { - isSuccessful: true, - timeAdded: timeAdded - }, - request: { - responseMsToLive: msToLive - } - })); - const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2'; - reusable = serviceAsAny.isReusable(uuid); + spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); }); it('return an observable emitting false', () => { - reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + expect(valid).toBe(false); }) }); describe('when the given UUID has a value, a cached entry is found, its response was successful, and the response is not outdated', () => { - let reusable; + let valid; const now = 100000; const timeAdded = 99999; const msToLive = 100; + const requestEntry = { + completed: true, + response: { + isSuccessful: true, + timeAdded: timeAdded + }, + request: { + responseMsToLive: msToLive + } + }; beforeEach(() => { spyOn(Date.prototype, 'getTime').and.returnValue(now); - spyOn(service, 'getByUUID').and.returnValue(observableOf({ - response: { - isSuccessful: true, - timeAdded: timeAdded - }, - request: { - responseMsToLive: msToLive - } - })); - const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2'; - reusable = serviceAsAny.isReusable(uuid); + spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); }); it('return an observable emitting true', () => { - reusable.subscribe((isReusable) => expect(isReusable).toBe(true)); + expect(valid).toBe(true); }) }) - }) + }); + + describe('hasByHref', () => { + describe('when nothing is returned by getByHref', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(EMPTY); + }); + it('hasByHref should return false', () => { + const result = service.hasByHref(''); + expect(result).toBe(false); + }); + }); + + describe('when isValid returns false', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(undefined)); + spyOn(service as any, 'isValid').and.returnValue(false); + }); + it('hasByHref should return false', () => { + const result = service.hasByHref(''); + expect(result).toBe(false); + }); + }); + + describe('when isValid returns true', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(undefined)); + spyOn(service as any, 'isValid').and.returnValue(true); + }); + it('hasByHref should return true', () => { + const result = service.hasByHref(''); + expect(result).toBe(true); + }); + }); + }); }); diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 922f035139..83071382ed 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,38 +1,79 @@ -import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; -import { - distinctUntilChanged, - filter, - find, - first, - map, - mergeMap, - reduce, - startWith, - switchMap, - take, - tap -} from 'rxjs/operators'; -import { race as observableRace } from 'rxjs'; import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { hasNoValue, hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { Observable, race as observableRace } from 'rxjs'; +import { filter, find, map, mergeMap, take } from 'rxjs/operators'; +import { cloneDeep, remove } from 'lodash'; + +import { AppState } from '../../app.reducer'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; -import { coreSelector, CoreState } from '../core.reducers'; -import { IndexName, IndexState } from '../index/index.reducer'; -import { pathSelector } from '../shared/selectors'; +import { CoreState } from '../core.reducers'; +import { IndexName, IndexState, MetaIndexState } from '../index/index.reducer'; +import { + originalRequestUUIDFromRequestUUIDSelector, + requestIndexSelector, + uuidFromHrefSelector +} from '../index/index.selectors'; import { UUIDService } from '../shared/uuid.service'; import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions'; import { GetRequest, RestRequest } from './request.models'; - -import { RequestEntry } from './request.reducer'; +import { RequestEntry, RequestState } from './request.reducer'; import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; import { RestRequestMethod } from './rest-request-method'; -import { getResponseFromEntry } from '../shared/operators'; import { AddToIndexAction, RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { coreSelector } from '../core.selectors'; +/** + * The base selector function to select the request state in the store + */ +const requestCacheSelector = createSelector( + coreSelector, + (state: CoreState) => state['data/request'] +); + +/** + * Selector function to select a request entry by uuid from the cache + * @param uuid The uuid of the request + */ +const entryFromUUIDSelector = (uuid: string): MemoizedSelector => createSelector( + requestCacheSelector, + (state: RequestState) => { + return hasValue(state) ? state[uuid] : undefined; + } +); + +/** + * Create a selector that fetches a list of request UUIDs from a given index substate of which the request href + * contains a given substring + * @param selector MemoizedSelector to start from + * @param href Substring that the request's href should contain + */ +const uuidsFromHrefSubstringSelector = + (selector: MemoizedSelector, href: string): MemoizedSelector => createSelector( + selector, + (state: IndexState) => getUuidsFromHrefSubstring(state, href) + ); + +/** + * Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring + * @param state The IndexState + * @param href Substring that the request's href should contain + */ +const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => { + let result = []; + if (isNotEmpty(state)) { + result = Object.values(state) + .filter((value: string) => value.startsWith(href)); + } + return result; +}; + +/** + * A service to interact with the request state in the store + */ @Injectable() export class RequestService { private requestsOnTheirWayToTheStore: string[] = []; @@ -40,44 +81,16 @@ export class RequestService { constructor(private objectCache: ObjectCacheService, private uuidService: UUIDService, private store: Store, - private indexStore: Store) { - } - - private entryFromUUIDSelector(uuid: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/request', uuid); - } - - private uuidFromHrefSelector(href: string): MemoizedSelector { - return pathSelector(coreSelector, 'index', IndexName.REQUEST, href); - } - - private originalUUIDFromUUIDSelector(uuid: string): MemoizedSelector { - return pathSelector(coreSelector, 'index', IndexName.UUID_MAPPING, uuid); - } - - private uuidsFromHrefSubstringSelector(selector: MemoizedSelector, name: string, href: string): MemoizedSelector { - return createSelector(selector, (state: IndexState) => this.getUuidsFromHrefSubstring(state, name, href)); - } - - private getUuidsFromHrefSubstring(state: IndexState, name: string, href: string): string[] { - let result = []; - if (isNotEmpty(state)) { - const subState = state[name]; - if (isNotEmpty(subState)) { - for (const value in subState) { - if (value.indexOf(href) > -1) { - result = [...result, subState[value]]; - } - } - } - } - return result; + private indexStore: Store) { } generateRequestId(): string { return `client/${this.uuidService.generate()}`; } + /** + * Check if a request is currently pending + */ isPending(request: GetRequest): boolean { // first check requests that haven't made it to the store yet if (this.requestsOnTheirWayToTheStore.includes(request.href)) { @@ -91,25 +104,40 @@ export class RequestService { .subscribe((re: RequestEntry) => { isPending = (hasValue(re) && !re.completed) }); - return isPending; } + /** + * Retrieve a RequestEntry based on their uuid + */ getByUUID(uuid: string): Observable { return observableRace( - this.store.pipe(select(this.entryFromUUIDSelector(uuid))), + this.store.pipe(select(entryFromUUIDSelector(uuid))), this.store.pipe( - select(this.originalUUIDFromUUIDSelector(uuid)), - switchMap((originalUUID) => { - return this.store.pipe(select(this.entryFromUUIDSelector(originalUUID))) + select(originalRequestUUIDFromRequestUUIDSelector(uuid)), + mergeMap((originalUUID) => { + return this.store.pipe(select(entryFromUUIDSelector(originalUUID))) }, )) + ).pipe( + map((entry: RequestEntry) => { + // Headers break after being retrieved from the store (because of lazy initialization) + // Combining them with a new object fixes this issue + if (hasValue(entry) && hasValue(entry.request) && hasValue(entry.request.options) && hasValue(entry.request.options.headers)) { + entry = cloneDeep(entry); + entry.request.options.headers = Object.assign(new HttpHeaders(), entry.request.options.headers) + } + return entry; + }) ); } + /** + * Retrieve a RequestEntry based on their href + */ getByHref(href: string): Observable { return this.store.pipe( - select(this.uuidFromHrefSelector(href)), + select(uuidFromHrefSelector(href)), mergeMap((uuid: string) => this.getByUUID(uuid)) ); } @@ -120,12 +148,14 @@ export class RequestService { * @param {RestRequest} request The request to send out * @param {boolean} forceBypassCache When true, a new request is always dispatched */ - // TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed configure(request: RestRequest, forceBypassCache: boolean = false): void { const isGetRequest = request.method === RestRequestMethod.GET; - if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { + if (forceBypassCache) { + this.clearRequestsOnTheirWayToTheStore(request); + } + if (!isGetRequest || (forceBypassCache && !this.isPending(request)) || !this.isCachedOrPending(request)) { this.dispatchRequest(request); - if (isGetRequest && !forceBypassCache) { + if (isGetRequest) { this.trackRequestsOnTheirWayToTheStore(request); } } else { @@ -139,6 +169,29 @@ export class RequestService { } } + /** + * Convert request Payload to a URL-encoded string + * + * e.g. uriEncodeBody({param: value, param1: value1}) + * returns: param=value¶m1=value1 + * + * @param body + * The request Payload to convert + * @return string + * URL-encoded string + */ + public uriEncodeBody(body: any) { + let queryParams = ''; + if (isNotEmpty(body) && typeof body === 'object') { + Object.keys(body) + .forEach((param) => { + const paramValue = `${param}=${body[param]}`; + queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue); + }) + } + return encodeURI(queryParams); + } + /** * Remove all request cache providing (part of) the href * This also includes href-to-uuid index cache @@ -146,7 +199,7 @@ export class RequestService { */ removeByHrefSubstring(href: string) { this.store.pipe( - select(this.uuidsFromHrefSubstringSelector(pathSelector(coreSelector, 'index'), IndexName.REQUEST, href)), + select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)), take(1) ).subscribe((uuids: string[]) => { for (const uuid of uuids) { @@ -170,31 +223,11 @@ export class RequestService { * @param {GetRequest} request The request to check * @returns {boolean} True if the request is cached or still pending */ - private isCachedOrPending(request: GetRequest) { - let isCached = this.objectCache.hasBySelfLink(request.href); - if (isCached) { - const responses: Observable = this.isReusable(request.uuid).pipe( - filter((reusable: boolean) => reusable), - switchMap(() => { - return this.getByHref(request.href).pipe( - getResponseFromEntry(), - take(1) - ); - } - )); + private isCachedOrPending(request: GetRequest): boolean { + const inReqCache = this.hasByHref(request.href); + const inObjCache = this.objectCache.hasBySelfLink(request.href); + const isCached = inReqCache || inObjCache; - const errorResponses = responses.pipe(filter((response) => !response.isSuccessful), map(() => true)); // TODO add a configurable number of retries in case of an error. - const dsoSuccessResponses = responses.pipe( - filter((response) => response.isSuccessful && hasValue((response as DSOSuccessResponse).resourceSelfLinks)), - map((response: DSOSuccessResponse) => response.resourceSelfLinks), - map((resourceSelfLinks: string[]) => resourceSelfLinks - .every((selfLink) => this.objectCache.hasBySelfLink(selfLink)) - )); - - const otherSuccessResponses = responses.pipe(filter((response) => response.isSuccessful && !hasValue((response as DSOSuccessResponse).resourceSelfLinks)), map(() => true)); - - observableMerge(errorResponses, otherSuccessResponses, dsoSuccessResponses).subscribe((c) => isCached = c); - } const isPending = this.isPending(request); return isCached || isPending; } @@ -217,7 +250,7 @@ export class RequestService { */ private trackRequestsOnTheirWayToTheStore(request: GetRequest) { this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href]; - this.store.pipe(select(this.entryFromUUIDSelector(request.href)), + this.getByHref(request.href).pipe( filter((re: RequestEntry) => hasValue(re)), take(1) ).subscribe((re: RequestEntry) => { @@ -225,6 +258,19 @@ export class RequestService { }); } + /** + * This method remove requests that are on their way to the store. + */ + private clearRequestsOnTheirWayToTheStore(request: GetRequest) { + this.getByHref(request.href).pipe( + find((re: RequestEntry) => hasValue(re))) + .subscribe((re: RequestEntry) => { + if (!re.responsePending) { + remove(this.requestsOnTheirWayToTheStore, (item) => item === request.href); + } + }); + } + /** * Dispatch commit action to send all changes (for a certain method) to the server (buffer) * @param {RestRequestMethod} method RestRequestMethod for which the changes should be committed @@ -234,31 +280,39 @@ export class RequestService { } /** - * Check whether a Response should still be cached + * Check whether a cached response should still be valid * - * @param uuid - * the uuid of the entry to check + * @param entry + * the entry to check * @return boolean - * false if the uuid has no value, no entry could be found, the response was nog successful or its time to - * live has exceeded, true otherwise + * false if the uuid has no value, the response was not successful or its time to + * live was exceeded, true otherwise */ - private isReusable(uuid: string): Observable { - if (hasNoValue(uuid)) { - return observableOf(false); + private isValid(entry: RequestEntry): boolean { + if (hasValue(entry) && entry.completed && entry.response.isSuccessful) { + const timeOutdated = entry.response.timeAdded + entry.request.responseMsToLive; + const isOutDated = new Date().getTime() > timeOutdated; + return !isOutDated; } else { - const requestEntry$ = this.getByUUID(uuid); - return requestEntry$.pipe( - filter((entry: RequestEntry) => hasValue(entry) && hasValue(entry.response)), - map((entry: RequestEntry) => { - if (hasValue(entry) && entry.response.isSuccessful) { - const timeOutdated = entry.response.timeAdded + entry.request.responseMsToLive; - const isOutDated = new Date().getTime() > timeOutdated; - return !isOutDated; - } else { - return false; - } - }) - ); + return false; } } + + /** + * Check whether the request with the specified href is cached + * + * @param href + * The link of the request to check + * @return boolean + * true if the request with the specified href is cached, + * false otherwise + */ + hasByHref(href: string): boolean { + let result = false; + this.getByHref(href).pipe( + take(1) + ).subscribe((requestEntry: RequestEntry) => result = this.isValid(requestEntry)); + return result; + } + } diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 7ee2b60f89..9ab0104393 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -7,7 +7,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { hasValue } from '../../shared/empty.util'; import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; -import { Metadatum } from '../shared/metadatum.model'; +import { MetadataMap, MetadataValue } from '../shared/metadata.models'; @Injectable() export class SearchResponseParsingService implements ResponseParsingService { @@ -15,30 +15,37 @@ export class SearchResponseParsingService implements ResponseParsingService { } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.payload._embedded.searchResult; - const hitHighlights = payload._embedded.objects + // fallback for unexpected empty response + const emptyPayload = { + _embedded : { + objects: [] + } + }; + const payload = data.payload._embedded.searchResult || emptyPayload; + const hitHighlights: MetadataMap[] = payload._embedded.objects .map((object) => object.hitHighlights) .map((hhObject) => { + const mdMap: MetadataMap = {}; if (hhObject) { - return Object.keys(hhObject).map((key) => Object.assign(new Metadatum(), { - key: key, - value: hhObject[key].join('...') - })) - } else { - return []; + for (const key of Object.keys(hhObject)) { + const value: MetadataValue = Object.assign(new MetadataValue(), { value: hhObject[key].join('...'), language: null }); + mdMap[key] = [ value ]; + } } + return mdMap; }); const dsoSelfLinks = payload._embedded.objects .filter((object) => hasValue(object._embedded)) - .map((object) => object._embedded.dspaceObject) + .map((object) => object._embedded.indexableObject) // we don't need embedded collections, bitstreamformats, etc for search results. // And parsing them all takes up a lot of time. Throw them away to improve performance // until objs until partial results are supported by the rest api .map((dso) => Object.assign({}, dso, { _embedded: undefined })) .map((dso) => this.dsoParser.parse(request, { payload: dso, - statusCode: data.statusCode + statusCode: data.statusCode, + statusText: data.statusText })) .map((obj) => obj.resourceSelfLinks) .reduce((combined, thisElement) => [...combined, ...thisElement], []); @@ -46,7 +53,7 @@ export class SearchResponseParsingService implements ResponseParsingService { const objects = payload._embedded.objects .filter((object) => hasValue(object._embedded)) .map((object, index) => Object.assign({}, object, { - dspaceObject: dsoSelfLinks[index], + indexableObject: dsoSelfLinks[index], hitHighlights: hitHighlights[index], // we don't need embedded collections, bitstreamformats, etc for search results. // And parsing them all takes up a lot of time. Throw them away to improve performance @@ -55,6 +62,6 @@ export class SearchResponseParsingService implements ResponseParsingService { })); payload.objects = objects; const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload); - return new SearchSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(payload)); + return new SearchSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(payload)); } } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts index 17fb389707..d09d398d7c 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts @@ -8,5 +8,6 @@ export interface DSpaceRESTV2Response { page?: any; }, headers?: HttpHeaders, - statusCode: string + statusCode: number, + statusText: string } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts index 4893908627..a7aba56a3b 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts @@ -1,13 +1,20 @@ import { TestBed, inject } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { DSpaceRESTv2Service } from './dspace-rest-v2.service'; +import { DEFAULT_CONTENT_TYPE, DSpaceRESTv2Service } from './dspace-rest-v2.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { HttpHeaders } from '@angular/common/http'; describe('DSpaceRESTv2Service', () => { let dSpaceRESTv2Service: DSpaceRESTv2Service; let httpMock: HttpTestingController; const url = 'http://www.dspace.org/'; - const mockError = new ErrorEvent('test error'); + const mockError: any = { + statusCode: 0, + statusText: 'Unknown Error', + message: 'Http failure response for http://www.dspace.org/: 0 ' + }; beforeEach(() => { TestBed.configureTestingModule({ @@ -30,39 +37,94 @@ describe('DSpaceRESTv2Service', () => { const mockPayload = { page: 1 }; - const mockStatusCode = 'GREAT'; + const mockStatusCode = 200; + const mockStatusText = 'GREAT'; dSpaceRESTv2Service.get(url).subscribe((response) => { expect(response).toBeTruthy(); expect(response.statusCode).toEqual(mockStatusCode); + expect(response.statusText).toEqual(mockStatusText); expect(response.payload.page).toEqual(mockPayload.page); }); const req = httpMock.expectOne(url); expect(req.request.method).toBe('GET'); - req.flush(mockPayload, { statusText: mockStatusCode}); + req.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); + }); + it('should throw an error', () => { + dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => { + expect(err).toEqual(mockError); + }); + const req = httpMock.expectOne(url); + expect(req.request.method).toBe('GET'); + req.error(mockError); + }); + + it('should log an error', () => { + spyOn(console, 'log'); + + dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => { + expect(console.log).toHaveBeenCalled(); + }); + + const req = httpMock.expectOne(url); + expect(req.request.method).toBe('GET'); + req.error(mockError); + }); + + it('when no content-type header is provided, it should use application/json', () => { + dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}).subscribe(); + + const req = httpMock.expectOne(url); + expect(req.request.headers.get('Content-Type')).toContain(DEFAULT_CONTENT_TYPE); }); }); - it('should throw an error', () => { - dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => { - expect(err.error).toBe(mockError); + describe('#request', () => { + it('should return an Observable', () => { + const mockPayload = { + page: 1 + }; + const mockStatusCode = 200; + const mockStatusText = 'GREAT'; + + dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}).subscribe((response) => { + expect(response).toBeTruthy(); + expect(response.statusCode).toEqual(mockStatusCode); + expect(response.statusText).toEqual(mockStatusText); + expect(response.payload.page).toEqual(mockPayload.page); + }); + + const req = httpMock.expectOne(url); + expect(req.request.method).toBe('POST'); + req.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); }); - const req = httpMock.expectOne(url); - expect(req.request.method).toBe('GET'); - req.error(mockError); + it('when a content-type header is provided, it should not use application/json', () => { + let headers = new HttpHeaders(); + headers = headers.set('Content-Type', 'text/html'); + dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}, { headers }).subscribe(); + + const req = httpMock.expectOne(url); + expect(req.request.headers.get('Content-Type')).not.toContain(DEFAULT_CONTENT_TYPE); + }); + + it('when no content-type header is provided, it should use application/json', () => { + dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}).subscribe(); + + const req = httpMock.expectOne(url); + expect(req.request.headers.get('Content-Type')).toContain(DEFAULT_CONTENT_TYPE); + }); }); - it('should log an error', () => { - spyOn(console, 'log'); - - dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => { - expect(console.log).toHaveBeenCalled(); + describe('buildFormData', () => { + it('should return the correct data', () => { + const name = 'testname'; + const dso: DSpaceObject = { + name: name + } as DSpaceObject; + const formdata = dSpaceRESTv2Service.buildFormData(dso); + expect(formdata.get('name')).toBe(name); }); - - const req = httpMock.expectOne(url); - expect(req.request.method).toBe('GET'); - req.error(mockError); }); }); diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 7173e5ba0d..290f4be8a2 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -1,12 +1,15 @@ -import {throwError as observableThrowError, Observable } from 'rxjs'; -import {catchError, map} from 'rxjs/operators'; +import { Observable, throwError as observableThrowError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http' import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; import { HttpObserve } from '@angular/common/http/src/client'; import { RestRequestMethod } from '../data/rest-request-method'; +import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; +export const DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8'; export interface HttpOptions { body?: any; headers?: HttpHeaders; @@ -36,11 +39,23 @@ export class DSpaceRESTv2Service { * An Observable containing the response from the server */ get(absoluteURL: string): Observable { - return this.http.get(absoluteURL, { observe: 'response' }).pipe( - map((res: HttpResponse) => ({ payload: res.body, statusCode: res.statusText })), + const requestOptions = { + observe: 'response' as any, + headers: new HttpHeaders({'Content-Type': DEFAULT_CONTENT_TYPE}) + }; + return this.http.get(absoluteURL, requestOptions).pipe( + map((res: HttpResponse) => ({ + payload: res.body, + statusCode: res.status, + statusText: res.statusText + })), catchError((err) => { console.log('Error: ', err); - return observableThrowError(err); + return observableThrowError({ + statusCode: err.status, + statusText: err.statusText, + message: err.message + }); })); } @@ -53,25 +68,69 @@ export class DSpaceRESTv2Service { * the URL for the request * @param body * an optional body for the request + * @param options + * the HttpOptions object * @return {Observable} * An Observable containing the response from the server */ request(method: RestRequestMethod, url: string, body?: any, options?: HttpOptions): Observable { const requestOptions: HttpOptions = {}; requestOptions.body = body; - requestOptions.observe = 'response'; - if (options && options.headers) { - requestOptions.headers = Object.assign(new HttpHeaders(), options.headers); + if (method === RestRequestMethod.POST && isNotEmpty(body) && isNotEmpty(body.name)) { + requestOptions.body = this.buildFormData(body); } + requestOptions.observe = 'response'; + if (options && options.responseType) { requestOptions.responseType = options.responseType; } + + if (hasNoValue(options) || hasNoValue(options.headers)) { + requestOptions.headers = new HttpHeaders(); + } else { + requestOptions.headers = options.headers; + } + + if (!requestOptions.headers.has('Content-Type')) { + // Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers + requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE); + } return this.http.request(method, url, requestOptions).pipe( - map((res) => ({ payload: res.body, headers: res.headers, statusCode: res.statusText })), + map((res) => ({ + payload: res.body, + headers: res.headers, + statusCode: res.status, + statusText: res.statusText + })), catchError((err) => { console.log('Error: ', err); - return observableThrowError(err); + return observableThrowError({ + statusCode: err.status, + statusText: err.statusText, + message: err.message + }); })); } + /** + * Create a FormData object from a DSpaceObject + * + * @param {DSpaceObject} dso + * the DSpaceObject + * @return {FormData} + * the result + */ + buildFormData(dso: DSpaceObject): FormData { + const form: FormData = new FormData(); + form.append('name', dso.name); + if (dso.metadata) { + for (const key of Object.keys(dso.metadata)) { + for (const value of dso.allMetadataValues(key)) { + form.append(key, value); + } + } + } + return form; + } + } diff --git a/src/app/core/eperson/eperson-response-parsing.service.ts b/src/app/core/eperson/eperson-response-parsing.service.ts new file mode 100644 index 0000000000..6c591b0b99 --- /dev/null +++ b/src/app/core/eperson/eperson-response-parsing.service.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@angular/core'; + +import { RestRequest } from '../data/request.models'; +import { ResponseParsingService } from '../data/parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { EpersonSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { ResourceType } from '../shared/resource-type'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +/** + * Provides method to parse response from eperson endpoint. + */ +@Injectable() +export class EpersonResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = false; + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { + super(); + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { + const epersonDefinition = this.process(data.payload, request.href); + return new EpersonSuccessResponse(epersonDefinition[Object.keys(epersonDefinition)[0]], data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from EPerson endpoint'), + {statusCode: data.statusCode, statusText: data.statusText} + ) + ); + } + } + +} diff --git a/src/app/core/eperson/eperson.service.ts b/src/app/core/eperson/eperson.service.ts new file mode 100644 index 0000000000..70ecf3f59e --- /dev/null +++ b/src/app/core/eperson/eperson.service.ts @@ -0,0 +1,14 @@ +import { Observable } from 'rxjs'; +import { FindAllOptions } from '../data/request.models'; +import { DataService } from '../data/data.service'; +import { CacheableObject } from '../cache/object-cache.reducer'; + +/** + * An abstract class that provides methods to make HTTP request to eperson endpoint. + */ +export abstract class EpersonService extends DataService { + + public getBrowseEndpoint(options: FindAllOptions): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/eperson/group-eperson.service.ts b/src/app/core/eperson/group-eperson.service.ts new file mode 100644 index 0000000000..07a1bb6aba --- /dev/null +++ b/src/app/core/eperson/group-eperson.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter, map, take } from 'rxjs/operators'; + +import { EpersonService } from './eperson.service'; +import { RequestService } from '../data/request.service'; +import { FindAllOptions } from '../data/request.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Group } from './models/group.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { SearchParam } from '../cache/models/search-param.model'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; + +/** + * Provides methods to retrieve eperson group resources. + */ +@Injectable() +export class GroupEpersonService extends EpersonService { + protected linkPath = 'groups'; + protected browseEndpoint = ''; + protected forceBypassCache = false; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService + ) { + super(); + } + + /** + * Check if the current user is member of to the indicated group + * + * @param groupName + * the group name + * @return boolean + * true if user is member of the indicated group, false otherwise + */ + isMemberOf(groupName: string): Observable { + const searchHref = 'isMemberOf'; + const options = new FindAllOptions(); + options.searchParams = [new SearchParam('groupName', groupName)]; + + return this.searchBy(searchHref, options).pipe( + filter((groups: RemoteData>) => !groups.isResponsePending), + take(1), + map((groups: RemoteData>) => groups.payload.totalElements > 0) + ); + } + +} diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index 45d26761b0..f8c11c1201 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -1,22 +1,56 @@ +import { Observable } from 'rxjs'; + import { DSpaceObject } from '../../shared/dspace-object.model'; import { Group } from './group.model'; +import { RemoteData } from '../../data/remote-data'; +import { PaginatedList } from '../../data/paginated-list'; export class EPerson extends DSpaceObject { + /** + * A string representing the unique handle of this Collection + */ public handle: string; - public groups: Group[]; + /** + * List of Groups that this EPerson belong to + */ + public groups: Observable>>; + /** + * A string representing the netid of this EPerson + */ public netid: string; + /** + * A string representing the last active date for this EPerson + */ public lastActive: string; + /** + * A boolean representing if this EPerson can log in + */ public canLogIn: boolean; + /** + * The EPerson email address + */ public email: string; + /** + * A boolean representing if this EPerson require certificate + */ public requireCertificate: boolean; + /** + * A boolean representing if this EPerson registered itself + */ public selfRegistered: boolean; + /** + * Getter to retrieve the EPerson's full name as a string + */ + get name(): string { + return this.firstMetadataValue('eperson.firstname') + ' ' + this.firstMetadataValue('eperson.lastname'); + } } diff --git a/src/app/core/eperson/models/group.model.ts b/src/app/core/eperson/models/group.model.ts index cd41ce9e25..91ce5d90f3 100644 --- a/src/app/core/eperson/models/group.model.ts +++ b/src/app/core/eperson/models/group.model.ts @@ -1,8 +1,28 @@ +import { Observable } from 'rxjs'; + import { DSpaceObject } from '../../shared/dspace-object.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { RemoteData } from '../../data/remote-data'; export class Group extends DSpaceObject { + /** + * List of Groups that this Group belong to + */ + public groups: Observable>>; + + /** + * A string representing the unique handle of this Group + */ public handle: string; + /** + * A string representing the name of this Group + */ + public name: string; + + /** + * A string representing the name of this Group is permanent + */ public permanent: boolean; } diff --git a/src/app/core/eperson/models/normalized-eperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts index 9d0fa428e9..ad4b20ee80 100644 --- a/src/app/core/eperson/models/normalized-eperson.model.ts +++ b/src/app/core/eperson/models/normalized-eperson.model.ts @@ -1,4 +1,5 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; + import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; @@ -8,30 +9,54 @@ import { ResourceType } from '../../shared/resource-type'; @mapsTo(EPerson) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { + /** + * A string representing the unique handle of this EPerson + */ @autoserialize public handle: string; - @autoserialize + /** + * List of Groups that this EPerson belong to + */ + @deserialize @relationship(ResourceType.Group, true) groups: string[]; + /** + * A string representing the netid of this EPerson + */ @autoserialize public netid: string; + /** + * A string representing the last active date for this EPerson + */ @autoserialize public lastActive: string; + /** + * A boolean representing if this EPerson can log in + */ @autoserialize public canLogIn: boolean; + /** + * The EPerson email address + */ @autoserialize public email: string; + /** + * A boolean representing if this EPerson require certificate + */ @autoserialize public requireCertificate: boolean; + /** + * A boolean representing if this EPerson registered itself + */ @autoserialize public selfRegistered: boolean; } diff --git a/src/app/core/eperson/models/normalized-group.model.ts b/src/app/core/eperson/models/normalized-group.model.ts index be5995d9c5..f86bec8628 100644 --- a/src/app/core/eperson/models/normalized-group.model.ts +++ b/src/app/core/eperson/models/normalized-group.model.ts @@ -1,17 +1,38 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; + import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; import { Group } from './group.model'; +import { ResourceType } from '../../shared/resource-type'; @mapsTo(Group) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { + /** + * List of Groups that this Group belong to + */ + @deserialize + @relationship(ResourceType.Group, true) + groups: string[]; + + /** + * A string representing the unique handle of this Group + */ @autoserialize public handle: string; + /** + * A string representing the name of this Group + */ + @autoserialize + public name: string; + + /** + * A string representing the name of this Group is permanent + */ @autoserialize public permanent: boolean; } diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts index dccee3c914..61cf313ab1 100644 --- a/src/app/core/index/index.effects.ts +++ b/src/app/core/index/index.effects.ts @@ -1,4 +1,4 @@ -import { filter, map, tap } from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; @@ -12,11 +12,6 @@ import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions' import { hasValue } from '../../shared/empty.util'; import { IndexName } from './index.reducer'; import { RestRequestMethod } from '../data/rest-request-method'; -import { - AddMenuSectionAction, - MenuActionTypes, - RemoveMenuSectionAction -} from '../../shared/menu/menu.actions'; @Injectable() export class UUIDIndexEffects { diff --git a/src/app/core/index/index.reducer.spec.ts b/src/app/core/index/index.reducer.spec.ts index ffc2c9fadc..ef46c760c6 100644 --- a/src/app/core/index/index.reducer.spec.ts +++ b/src/app/core/index/index.reducer.spec.ts @@ -1,7 +1,7 @@ import * as deepFreeze from 'deep-freeze'; -import { IndexName, indexReducer, IndexState } from './index.reducer'; -import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions'; +import { IndexName, indexReducer, MetaIndexState } from './index.reducer'; +import { AddToIndexAction, RemoveFromIndexBySubstringAction, RemoveFromIndexByValueAction } from './index.actions'; class NullAction extends AddToIndexAction { type = null; @@ -17,7 +17,7 @@ describe('requestReducer', () => { const key2 = '1911e8a4-6939-490c-b58b-a5d70f8d91fb'; const val1 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/567a639f-f5ff-4126-807c-b7d0910808c8'; const val2 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/1911e8a4-6939-490c-b58b-a5d70f8d91fb'; - const testState: IndexState = { + const testState: MetaIndexState = { [IndexName.OBJECT]: { [key1]: val1 },[IndexName.REQUEST]: { @@ -59,4 +59,13 @@ describe('requestReducer', () => { expect(newState[IndexName.OBJECT][key1]).toBeUndefined(); }); + + it('should remove the given \'value\' from its corresponding \'key\' in the correct substate, in response to a REMOVE_BY_SUBSTRING action', () => { + const state = testState; + + const action = new RemoveFromIndexBySubstringAction(IndexName.OBJECT, key1); + const newState = indexReducer(state, action); + + expect(newState[IndexName.OBJECT][key1]).toBeUndefined(); + }); }); diff --git a/src/app/core/index/index.reducer.ts b/src/app/core/index/index.reducer.ts index dae7874794..b4cd8aa84b 100644 --- a/src/app/core/index/index.reducer.ts +++ b/src/app/core/index/index.reducer.ts @@ -1,27 +1,57 @@ import { + AddToIndexAction, IndexAction, IndexActionTypes, - AddToIndexAction, - RemoveFromIndexByValueAction, - RemoveFromIndexBySubstringAction + RemoveFromIndexBySubstringAction, + RemoveFromIndexByValueAction } from './index.actions'; +/** + * An enum containing all index names + */ export enum IndexName { + // Contains all objects in the object cache indexed by UUID OBJECT = 'object/uuid-to-self-link', + + // contains all requests in the request cache indexed by UUID REQUEST = 'get-request/href-to-uuid', + + /** + * Contains the UUIDs of requests that were sent to the server and + * have their responses cached, indexed by the UUIDs of requests that + * weren't sent because the response they requested was already cached + */ UUID_MAPPING = 'get-request/configured-to-cache-uuid' } -export type IndexState = { - [name in IndexName]: { - [key: string]: string - } +/** + * The state of a single index + */ +export interface IndexState { + [key: string]: string +} + +/** + * The state that contains all indices + */ +export type MetaIndexState = { + [name in IndexName]: IndexState } // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) -const initialState: IndexState = Object.create(null); +const initialState: MetaIndexState = Object.create(null); -export function indexReducer(state = initialState, action: IndexAction): IndexState { +/** + * The Index Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return MetaIndexState + * the new state + */ +export function indexReducer(state = initialState, action: IndexAction): MetaIndexState { switch (action.type) { case IndexActionTypes.ADD: { @@ -42,7 +72,17 @@ export function indexReducer(state = initialState, action: IndexAction): IndexSt } } -function addToIndex(state: IndexState, action: AddToIndexAction): IndexState { +/** + * Add an entry to a given index + * + * @param state + * The MetaIndexState that contains all indices + * @param action + * The AddToIndexAction containing the value to add, and the index to add it to + * @return MetaIndexState + * the new state + */ +function addToIndex(state: MetaIndexState, action: AddToIndexAction): MetaIndexState { const subState = state[action.payload.name]; const newSubState = Object.assign({}, subState, { [action.payload.key]: action.payload.value @@ -53,7 +93,17 @@ function addToIndex(state: IndexState, action: AddToIndexAction): IndexState { return obs; } -function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValueAction): IndexState { +/** + * Remove a entries that contain a given value from a given index + * + * @param state + * The MetaIndexState that contains all indices + * @param action + * The RemoveFromIndexByValueAction containing the value to remove, and the index to remove it from + * @return MetaIndexState + * the new state + */ +function removeFromIndexByValue(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState { const subState = state[action.payload.name]; const newSubState = Object.create(null); for (const value in subState) { @@ -66,7 +116,17 @@ function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValu }); } -function removeFromIndexBySubstring(state: IndexState, action: RemoveFromIndexByValueAction): IndexState { +/** + * Remove entries that contain a given substring from a given index + * + * @param state + * The MetaIndexState that contains all indices + * @param action + * The RemoveFromIndexByValueAction the substring to remove, and the index to remove it from + * @return MetaIndexState + * the new state + */ +function removeFromIndexBySubstring(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState { const subState = state[action.payload.name]; const newSubState = Object.create(null); for (const value in subState) { diff --git a/src/app/core/index/index.selectors.ts b/src/app/core/index/index.selectors.ts new file mode 100644 index 0000000000..3c7b331a92 --- /dev/null +++ b/src/app/core/index/index.selectors.ts @@ -0,0 +1,94 @@ +import { createSelector, MemoizedSelector } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { hasValue } from '../../shared/empty.util'; +import { CoreState } from '../core.reducers'; +import { coreSelector } from '../core.selectors'; +import { IndexName, IndexState, MetaIndexState } from './index.reducer'; + +/** + * Return the MetaIndexState based on the CoreSate + * + * @returns + * a MemoizedSelector to select the MetaIndexState + */ +export const metaIndexSelector: MemoizedSelector = createSelector( + coreSelector, + (state: CoreState) => state.index +); + +/** + * Return the object index based on the MetaIndexState + * It contains all objects in the object cache indexed by UUID + * + * @returns + * a MemoizedSelector to select the object index + */ +export const objectIndexSelector: MemoizedSelector = createSelector( + metaIndexSelector, + (state: MetaIndexState) => state[IndexName.OBJECT] +); + +/** + * Return the request index based on the MetaIndexState + * + * @returns + * a MemoizedSelector to select the request index + */ +export const requestIndexSelector: MemoizedSelector = createSelector( + metaIndexSelector, + (state: MetaIndexState) => state[IndexName.REQUEST] +); + +/** + * Return the request UUID mapping index based on the MetaIndexState + * + * @returns + * a MemoizedSelector to select the request UUID mapping + */ +export const requestUUIDIndexSelector: MemoizedSelector = createSelector( + metaIndexSelector, + (state: MetaIndexState) => state[IndexName.UUID_MAPPING] +); + +/** + * Return the self link of an object in the object-cache based on its UUID + * + * @param uuid + * the UUID for which you want to find the matching self link + * @returns + * a MemoizedSelector to select the self link + */ +export const selfLinkFromUuidSelector = + (uuid: string): MemoizedSelector => createSelector( + objectIndexSelector, + (state: IndexState) => hasValue(state) ? state[uuid] : undefined + ); + +/** + * Return the UUID of a GET request based on its href + * + * @param href + * the href of the GET request + * @returns + * a MemoizedSelector to select the UUID + */ +export const uuidFromHrefSelector = + (href: string): MemoizedSelector => createSelector( + requestIndexSelector, + (state: IndexState) => hasValue(state) ? state[href] : undefined + ); + +/** + * Return the UUID of a cached request based on the UUID of a request + * that wasn't sent because the response was already cached + * + * @param uuid + * The UUID of the new request + * @returns + * a MemoizedSelector to select the UUID of the cached request + */ +export const originalRequestUUIDFromRequestUUIDSelector = + (uuid: string): MemoizedSelector => createSelector( + requestUUIDIndexSelector, + (state: IndexState) => hasValue(state) ? state[uuid] : undefined + ); diff --git a/src/app/core/integration/authority.service.ts b/src/app/core/integration/authority.service.ts index a5fa3a8d09..f0a1759be6 100644 --- a/src/app/core/integration/authority.service.ts +++ b/src/app/core/integration/authority.service.ts @@ -3,15 +3,19 @@ import { Injectable } from '@angular/core'; import { RequestService } from '../data/request.service'; import { IntegrationService } from './integration.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @Injectable() export class AuthorityService extends IntegrationService { protected linkPath = 'authorities'; - protected browseEndpoint = 'entries'; + protected entriesEndpoint = 'entries'; + protected entryValueEndpoint = 'entryValues'; constructor( protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService) { super(); } + } diff --git a/src/app/core/integration/integration-object-factory.ts b/src/app/core/integration/integration-object-factory.ts index 4f69dbd6fe..f66a070fdf 100644 --- a/src/app/core/integration/integration-object-factory.ts +++ b/src/app/core/integration/integration-object-factory.ts @@ -1,13 +1,13 @@ import { GenericConstructor } from '../shared/generic-constructor'; import { IntegrationType } from './intergration-type'; -import { AuthorityValueModel } from './models/authority-value.model'; import { IntegrationModel } from './models/integration.model'; +import { NormalizedAuthorityValue } from './models/normalized-authority-value.model'; export class IntegrationObjectFactory { public static getConstructor(type): GenericConstructor { switch (type) { case IntegrationType.Authority: { - return AuthorityValueModel; + return NormalizedAuthorityValue; } default: { return undefined; diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts index 38741da4e2..4187606265 100644 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ b/src/app/core/integration/integration-response-parsing.service.spec.ts @@ -7,7 +7,7 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { IntegrationResponseParsingService } from './integration-response-parsing.service'; import { IntegrationRequest } from '../data/request.models'; -import { AuthorityValueModel } from './models/authority-value.model'; +import { AuthorityValue } from './models/authority.value'; import { PageInfo } from '../shared/page-info.model'; import { PaginatedList } from '../data/paginated-list'; @@ -23,15 +23,57 @@ describe('IntegrationResponseParsingService', () => { const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const integrationEndpoint = 'https://rest.api/integration/authorities'; const entriesEndpoint = `${integrationEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`; + let validRequest; - beforeEach(() => { - service = new IntegrationResponseParsingService(EnvConfig, objectCacheService); - }); + let validResponse; - describe('parse', () => { - const validRequest = new IntegrationRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', entriesEndpoint); + let invalidResponse1; + let invalidResponse2; + let pageInfo; + let definitions; - const validResponse = { + function initVars() { + pageInfo = Object.assign(new PageInfo(), { elementsPerPage: 5, totalElements: 5, totalPages: 1, currentPage: 1, self: 'https://rest.api/integration/authorities/type/entries'}); + definitions = new PaginatedList(pageInfo,[ + Object.assign(new AuthorityValue(), { + type: 'authority', + display: 'One', + id: 'One', + otherInformation: undefined, + value: 'One' + }), + Object.assign(new AuthorityValue(), { + type: 'authority', + display: 'Two', + id: 'Two', + otherInformation: undefined, + value: 'Two' + }), + Object.assign(new AuthorityValue(), { + type: 'authority', + display: 'Three', + id: 'Three', + otherInformation: undefined, + value: 'Three' + }), + Object.assign(new AuthorityValue(), { + type: 'authority', + display: 'Four', + id: 'Four', + otherInformation: undefined, + value: 'Four' + }), + Object.assign(new AuthorityValue(), { + type: 'authority', + display: 'Five', + id: 'Five', + otherInformation: undefined, + value: 'Five' + }) + ]); + validRequest = new IntegrationRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', entriesEndpoint); + + validResponse = { payload: { page: { number: 0, @@ -83,15 +125,17 @@ describe('IntegrationResponseParsingService', () => { self: { href: 'https://rest.api/integration/authorities/type/entries' } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; - const invalidResponse1 = { + invalidResponse1 = { payload: {}, - statusCode: '200' + statusCode: 400, + statusText: 'Bad Request' }; - const invalidResponse2 = { + invalidResponse2 = { payload: { page: { number: 0, @@ -141,47 +185,16 @@ describe('IntegrationResponseParsingService', () => { }, _links: {} }, - statusCode: '200' + statusCode: 500, + statusText: 'Internal Server Error' }; - const pageinfo = Object.assign(new PageInfo(), { elementsPerPage: 5, totalElements: 5, totalPages: 1, currentPage: 1 }); - const definitions = new PaginatedList(pageinfo,[ - Object.assign({}, new AuthorityValueModel(), { - type: 'authority', - display: 'One', - id: 'One', - otherInformation: undefined, - value: 'One' - }), - Object.assign({}, new AuthorityValueModel(), { - type: 'authority', - display: 'Two', - id: 'Two', - otherInformation: undefined, - value: 'Two' - }), - Object.assign({}, new AuthorityValueModel(), { - type: 'authority', - display: 'Three', - id: 'Three', - otherInformation: undefined, - value: 'Three' - }), - Object.assign({}, new AuthorityValueModel(), { - type: 'authority', - display: 'Four', - id: 'Four', - otherInformation: undefined, - value: 'Four' - }), - Object.assign({}, new AuthorityValueModel(), { - type: 'authority', - display: 'Five', - id: 'Five', - otherInformation: undefined, - value: 'Five' - }) - ]); + } + beforeEach(() => { + initVars(); + service = new IntegrationResponseParsingService(EnvConfig, objectCacheService); + }); + describe('parse', () => { it('should return a IntegrationSuccessResponse if data contains a valid endpoint response', () => { const response = service.parse(validRequest, validResponse); expect(response.constructor).toBe(IntegrationSuccessResponse); diff --git a/src/app/core/integration/integration-response-parsing.service.ts b/src/app/core/integration/integration-response-parsing.service.ts index ef278c93de..2d3693cf3d 100644 --- a/src/app/core/integration/integration-response-parsing.service.ts +++ b/src/app/core/integration/integration-response-parsing.service.ts @@ -16,12 +16,14 @@ import { GlobalConfig } from '../../../config/global-config.interface'; import { ObjectCacheService } from '../cache/object-cache.service'; import { IntegrationModel } from './models/integration.model'; import { IntegrationType } from './intergration-type'; +import { AuthorityValue } from './models/authority.value'; +import { PaginatedList } from '../data/paginated-list'; @Injectable() export class IntegrationResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected objectFactory = IntegrationObjectFactory; - protected toCache = false; + protected toCache = true; constructor( @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @@ -33,15 +35,26 @@ export class IntegrationResponseParsingService extends BaseResponseParsingServic parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { const dataDefinition = this.process(data.payload, request.uuid); - return new IntegrationSuccessResponse(dataDefinition, data.statusCode, this.processPageInfo(data.payload.page)); + return new IntegrationSuccessResponse(this.processResponse(dataDefinition), data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from Integration endpoint'), - {statusText: data.statusCode} + {statusCode: data.statusCode, statusText: data.statusText} ) ); } } + protected processResponse(data: PaginatedList): any { + const returnList = Array.of(); + data.page.forEach((item, index) => { + if (item.type === IntegrationType.Authority) { + data.page[index] = Object.assign(new AuthorityValue(), item); + } + }); + + return data; + } + } diff --git a/src/app/core/integration/integration.service.spec.ts b/src/app/core/integration/integration.service.spec.ts index 152d7ab165..02fff950ed 100644 --- a/src/app/core/integration/integration.service.spec.ts +++ b/src/app/core/integration/integration.service.spec.ts @@ -8,16 +8,21 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { IntegrationService } from './integration.service'; import { IntegrationSearchOptions } from './models/integration-options.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; const LINK_NAME = 'authorities'; -const BROWSE = 'entries'; +const ENTRIES = 'entries'; +const ENTRY_VALUE = 'entryValue'; class TestService extends IntegrationService { protected linkPath = LINK_NAME; - protected browseEndpoint = BROWSE; + protected entriesEndpoint = ENTRIES; + protected entryValueEndpoint = ENTRY_VALUE; constructor( protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService) { super(); } @@ -27,28 +32,33 @@ describe('IntegrationService', () => { let scheduler: TestScheduler; let service: TestService; let requestService: RequestService; + let rdbService: RemoteDataBuildService; let halService: any; let findOptions: IntegrationSearchOptions; const name = 'type'; const metadata = 'dc.type'; const query = ''; + const value = 'test'; const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const integrationEndpoint = 'https://rest.api/integration'; const serviceEndpoint = `${integrationEndpoint}/${LINK_NAME}`; const entriesEndpoint = `${serviceEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`; + const entryValueEndpoint = `${serviceEndpoint}/${name}/entryValue/${value}?metadata=${metadata}`; findOptions = new IntegrationSearchOptions(uuid, name, metadata); function initTestService(): TestService { return new TestService( requestService, + rdbService, halService ); } beforeEach(() => { requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(integrationEndpoint); findOptions = new IntegrationSearchOptions(uuid, name, metadata, query); @@ -67,4 +77,20 @@ describe('IntegrationService', () => { }); }); + describe('getEntryByValue', () => { + + it('should configure a new IntegrationRequest', () => { + findOptions = new IntegrationSearchOptions( + null, + name, + metadata, + value); + + const expected = new IntegrationRequest(requestService.generateRequestId(), entryValueEndpoint); + scheduler.schedule(() => service.getEntryByValue(findOptions).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); }); diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts index 2ace710dc7..5826f4646d 100644 --- a/src/app/core/integration/integration.service.ts +++ b/src/app/core/integration/integration.service.ts @@ -7,23 +7,25 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { IntegrationData } from './integration-data'; import { IntegrationSearchOptions } from './models/integration-options.model'; -import { RequestEntry } from '../data/request.reducer'; import { getResponseFromEntry } from '../shared/operators'; export abstract class IntegrationService { protected request: IntegrationRequest; protected abstract requestService: RequestService; protected abstract linkPath: string; - protected abstract browseEndpoint: string; + protected abstract entriesEndpoint: string; + protected abstract entryValueEndpoint: string; protected abstract halService: HALEndpointService; protected getData(request: GetRequest): Observable { return this.requestService.getByHref(request.href).pipe( getResponseFromEntry(), - mergeMap((response) => { + mergeMap((response: IntegrationSuccessResponse) => { if (response.isSuccessful && isNotEmpty(response)) { - const dataResponse = response as IntegrationSuccessResponse; - return observableOf(new IntegrationData(dataResponse.pageInfo, dataResponse.dataDefinition)); + return observableOf(new IntegrationData( + response.pageInfo, + (response.dataDefinition) ? response.dataDefinition.page : [] + )); } else if (!response.isSuccessful) { return observableThrowError(new Error(`Couldn't retrieve the integration data`)); } @@ -32,12 +34,12 @@ export abstract class IntegrationService { ); } - protected getIntegrationHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { + protected getEntriesHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { let result; const args = []; if (hasValue(options.name)) { - result = `${endpoint}/${options.name}/${this.browseEndpoint}`; + result = `${endpoint}/${options.name}/${this.entriesEndpoint}`; } else { result = endpoint; } @@ -73,9 +75,41 @@ export abstract class IntegrationService { return result; } + protected getEntryValueHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { + let result; + const args = []; + + if (hasValue(options.name) && hasValue(options.query)) { + result = `${endpoint}/${options.name}/${this.entryValueEndpoint}/${options.query}`; + } else { + result = endpoint; + } + + if (hasValue(options.metadata)) { + args.push(`metadata=${options.metadata}`); + } + + if (isNotEmpty(args)) { + result = `${result}?${args.join('&')}`; + } + + return result; + } + public getEntriesByName(options: IntegrationSearchOptions): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIntegrationHref(endpoint, options)), + map((endpoint: string) => this.getEntriesHref(endpoint, options)), + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: GetRequest) => this.requestService.configure(request)), + mergeMap((request: GetRequest) => this.getData(request)), + distinctUntilChanged()); + } + + public getEntryByValue(options: IntegrationSearchOptions): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getEntryValueHref(endpoint, options)), filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), diff --git a/src/app/core/integration/models/authority-value.model.ts b/src/app/core/integration/models/authority-value.model.ts deleted file mode 100644 index e2ef9ce9db..0000000000 --- a/src/app/core/integration/models/authority-value.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IntegrationModel } from './integration.model'; -import { autoserialize } from 'cerialize'; - -export class AuthorityValueModel extends IntegrationModel { - - @autoserialize - id: string; - - @autoserialize - display: string; - - @autoserialize - value: string; - - @autoserialize - otherInformation: any; - - @autoserialize - language: string; -} diff --git a/src/app/core/integration/models/authority.value.ts b/src/app/core/integration/models/authority.value.ts new file mode 100644 index 0000000000..31cb0a5787 --- /dev/null +++ b/src/app/core/integration/models/authority.value.ts @@ -0,0 +1,72 @@ +import { IntegrationModel } from './integration.model'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { MetadataValueInterface } from '../../shared/metadata.models'; + +/** + * Class representing an authority object + */ +export class AuthorityValue extends IntegrationModel implements MetadataValueInterface { + + /** + * The identifier of this authority + */ + id: string; + + /** + * The display value of this authority + */ + display: string; + + /** + * The value of this authority + */ + value: string; + + /** + * An object containing additional information related to this authority + */ + otherInformation: OtherInformation; + + /** + * The language code of this authority value + */ + language: string; + + /** + * This method checks if authority has an identifier value + * + * @return boolean + */ + hasAuthority(): boolean { + return isNotEmpty(this.id); + } + + /** + * This method checks if authority has a value + * + * @return boolean + */ + hasValue(): boolean { + return isNotEmpty(this.value); + } + + /** + * This method checks if authority has related information object + * + * @return boolean + */ + hasOtherInformation(): boolean { + return isNotEmpty(this.otherInformation); + } + + /** + * This method checks if authority has a placeholder as value + * + * @return boolean + */ + hasPlaceholder(): boolean { + return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; + } +} diff --git a/src/app/core/integration/models/confidence-type.ts b/src/app/core/integration/models/confidence-type.ts new file mode 100644 index 0000000000..3630d02970 --- /dev/null +++ b/src/app/core/integration/models/confidence-type.ts @@ -0,0 +1,44 @@ +export enum ConfidenceType { + /** + * This authority value has been confirmed as accurate by an + * interactive user or authoritative policy + */ + CF_ACCEPTED = 600, + + /** + * Value is singular and valid but has not been seen and accepted + * by a human, so its provenance is uncertain. + */ + CF_UNCERTAIN = 500, + + /** + * There are multiple matching authority values of equal validity. + */ + CF_AMBIGUOUS = 400, + + /** + * There are no matching answers from the authority. + */ + CF_NOTFOUND = 300, + + /** + * The authority encountered an internal failure - this preserves a + * record in the metadata of why there is no value. + */ + CF_FAILED = 200, + + /** + * The authority recommends this submission be rejected. + */ + CF_REJECTED = 100, + + /** + * No reasonable confidence value is available + */ + CF_NOVALUE = 0, + + /** + * Value has not been set (DB default). + */ + CF_UNSET = -1 +} diff --git a/src/app/core/integration/models/integration.model.ts b/src/app/core/integration/models/integration.model.ts index d3383ab94a..3158abc7eb 100644 --- a/src/app/core/integration/models/integration.model.ts +++ b/src/app/core/integration/models/integration.model.ts @@ -1,12 +1,20 @@ import { autoserialize } from 'cerialize'; +import { CacheableObject } from '../../cache/object-cache.reducer'; -export abstract class IntegrationModel { +export abstract class IntegrationModel implements CacheableObject { @autoserialize - public type: string; + self: string; + + @autoserialize + uuid: string; + + @autoserialize + public type: any; @autoserialize public _links: { [name: string]: string } + } diff --git a/src/app/core/integration/models/normalized-authority-value.model.ts b/src/app/core/integration/models/normalized-authority-value.model.ts new file mode 100644 index 0000000000..5ebb61281d --- /dev/null +++ b/src/app/core/integration/models/normalized-authority-value.model.ts @@ -0,0 +1,28 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { IntegrationModel } from './integration.model'; +import { mapsTo } from '../../cache/builders/build-decorators'; +import { AuthorityValue } from './authority.value'; + +/** + * Normalized model class for an Authority Value + */ +@mapsTo(AuthorityValue) +@inheritSerialization(IntegrationModel) +export class NormalizedAuthorityValue extends IntegrationModel { + + @autoserialize + id: string; + + @autoserialize + display: string; + + @autoserialize + value: string; + + @autoserialize + otherInformation: any; + + @autoserialize + language: string; + +} diff --git a/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts b/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts new file mode 100644 index 0000000000..d29bf993cc --- /dev/null +++ b/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts @@ -0,0 +1,57 @@ +import { isNotUndefined } from '../../../shared/empty.util'; +import { URLCombiner } from '../../url-combiner/url-combiner'; + +/** + * Interface used to represent a JSON-PATCH path member + * in JsonPatchOperationsState + */ +export interface JsonPatchOperationPathObject { + rootElement: string; + subRootElement: string; + path: string; +} + +/** + * Combines a variable number of strings representing parts + * of a JSON-PATCH path + */ +export class JsonPatchOperationPathCombiner extends URLCombiner { + private _rootElement: string; + private _subRootElement: string; + + constructor(rootElement, ...subRootElements: string[]) { + super(rootElement, ...subRootElements); + this._rootElement = rootElement; + this._subRootElement = subRootElements.join('/'); + } + + get rootElement(): string { + return this._rootElement; + } + + get subRootElement(): string { + return this._subRootElement; + } + + /** + * Combines the parts of this JsonPatchOperationPathCombiner in to a JSON-PATCH path member + * + * e.g. new JsonPatchOperationPathCombiner('sections', 'basic').getPath(['dc.title', '0']) + * returns: {rootElement: 'sections', subRootElement: 'basic', path: '/sections/basic/dc.title/0'} + * + * @return {JsonPatchOperationPathObject} + * The combined path object + */ + public getPath(fragment?: string|string[]): JsonPatchOperationPathObject { + if (isNotUndefined(fragment) && Array.isArray(fragment)) { + fragment = fragment.join('/'); + } + + let path = '/' + this.toString(); + if (isNotUndefined(fragment)) { + path += '/' + fragment; + } + + return {rootElement: this._rootElement, subRootElement: this._subRootElement, path: path}; + } +} diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts new file mode 100644 index 0000000000..c45183b4ef --- /dev/null +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -0,0 +1,138 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { + NewPatchAddOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction +} from '../json-patch-operations.actions'; +import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner'; +import { Injectable } from '@angular/core'; +import { isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { dateToISOFormat } from '../../../shared/date.util'; +import { AuthorityValue } from '../../integration/models/authority.value'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; + +/** + * Provides methods to dispatch JsonPatch Operations Actions + */ +@Injectable() +export class JsonPatchOperationsBuilder { + + constructor(private store: Store) { + } + + /** + * Dispatches a new NewPatchAddOperationAction + * + * @param path + * a JsonPatchOperationPathObject representing path + * @param value + * The value to update the referenced path + * @param first + * A boolean representing if the value to be added is the first of an array + * @param plain + * A boolean representing if the value to be added is a plain text value + */ + add(path: JsonPatchOperationPathObject, value, first = false, plain = false) { + this.store.dispatch( + new NewPatchAddOperationAction( + path.rootElement, + path.subRootElement, + path.path, this.prepareValue(value, plain, first))); + } + + /** + * Dispatches a new NewPatchReplaceOperationAction + * + * @param path + * a JsonPatchOperationPathObject representing path + * @param value + * the value to update the referenced path + * @param plain + * a boolean representing if the value to be added is a plain text value + */ + replace(path: JsonPatchOperationPathObject, value, plain = false) { + this.store.dispatch( + new NewPatchReplaceOperationAction( + path.rootElement, + path.subRootElement, + path.path, + this.prepareValue(value, plain, false))); + } + + /** + * Dispatches a new NewPatchRemoveOperationAction + * + * @param path + * a JsonPatchOperationPathObject representing path + */ + remove(path: JsonPatchOperationPathObject) { + this.store.dispatch( + new NewPatchRemoveOperationAction( + path.rootElement, + path.subRootElement, + path.path)); + } + + protected prepareValue(value: any, plain: boolean, first: boolean) { + let operationValue: any = null; + if (isNotEmpty(value)) { + if (plain) { + operationValue = value; + } else { + if (Array.isArray(value)) { + operationValue = []; + value.forEach((entry) => { + if ((typeof entry === 'object')) { + operationValue.push(this.prepareObjectValue(entry)); + } else { + operationValue.push(new FormFieldMetadataValueObject(entry)); + } + }); + } else if (typeof value === 'object') { + operationValue = this.prepareObjectValue(value); + } else { + operationValue = new FormFieldMetadataValueObject(value); + } + } + } + return (first && !Array.isArray(operationValue)) ? [operationValue] : operationValue; + } + + protected prepareObjectValue(value: any) { + let operationValue = Object.create({}); + if (isEmpty(value) || value instanceof FormFieldMetadataValueObject) { + operationValue = value; + } else if (value instanceof Date) { + operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value)); + } else if (value instanceof AuthorityValue) { + operationValue = this.prepareAuthorityValue(value); + } else if (value instanceof FormFieldLanguageValueObject) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language); + } else if (value.hasOwnProperty('value')) { + operationValue = new FormFieldMetadataValueObject(value.value); + } else { + Object.keys(value) + .forEach((key) => { + if (typeof value[key] === 'object') { + operationValue[key] = this.prepareObjectValue(value[key]); + } else { + operationValue[key] = value[key]; + } + }); + } + return operationValue; + } + + protected prepareAuthorityValue(value: any) { + let operationValue: any = null; + if (isNotEmpty(value.id)) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id); + } else { + operationValue = new FormFieldMetadataValueObject(value.value, value.language); + } + return operationValue; + } + +} diff --git a/src/app/core/json-patch/json-patch-operations.actions.ts b/src/app/core/json-patch/json-patch-operations.actions.ts new file mode 100644 index 0000000000..cb3e3b0d38 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.actions.ts @@ -0,0 +1,279 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const JsonPatchOperationsActionTypes = { + NEW_JSON_PATCH_ADD_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_ADD_OPERATION'), + NEW_JSON_PATCH_COPY_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_COPY_OPERATION'), + NEW_JSON_PATCH_MOVE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_MOVE_OPERATION'), + NEW_JSON_PATCH_REMOVE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_REMOVE_OPERATION'), + NEW_JSON_PATCH_REPLACE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_REPLACE_OPERATION'), + COMMIT_JSON_PATCH_OPERATIONS: type('dspace/core/patch/COMMIT_JSON_PATCH_OPERATIONS'), + ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'), + FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'), + START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * An ngrx action to commit the current transaction + */ +export class CommitPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to rollback the current transaction + */ +export class RollbacktPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.ROLLBACK_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to initiate a transaction block + */ +export class StartTransactionPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.START_TRANSACTION_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + startTime: number; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param startTime + * the start timestamp + */ + constructor(resourceType: string, resourceId: string, startTime: number) { + this.payload = { resourceType, resourceId, startTime }; + } +} + +/** + * An ngrx action to flush list of the JSON Patch operations + */ +export class FlushPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new FlushPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to Add new HTTP/PATCH ADD operations to state + */ +export class NewPatchAddOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + value: any + }; + + /** + * Create a new NewPatchAddOperationAction + * + * @param resourceType + * the resource's type where to add operation + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + * @param value + * the operation's payload + */ + constructor(resourceType: string, resourceId: string, path: string, value: any) { + this.payload = { resourceType, resourceId, path, value }; + } +} + +/** + * An ngrx action to add new JSON Patch COPY operation to state + */ +export class NewPatchCopyOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_COPY_OPERATION; + payload: { + resourceType: string; + resourceId: string; + from: string; + path: string; + }; + + /** + * Create a new NewPatchCopyOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param from + * the path to copy the value from + * @param path + * the path where to copy the value + */ + constructor(resourceType: string, resourceId: string, from: string, path: string) { + this.payload = { resourceType, resourceId, from, path }; + } +} + +/** + * An ngrx action to Add new JSON Patch MOVE operation to state + */ +export class NewPatchMoveOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + from: string; + path: string; + }; + + /** + * Create a new NewPatchMoveOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param from + * the path to move the value from + * @param path + * the path where to move the value + */ + constructor(resourceType: string, resourceId: string, from: string, path: string) { + this.payload = { resourceType, resourceId, from, path }; + } +} + +/** + * An ngrx action to Add new JSON Patch REMOVE operation to state + */ +export class NewPatchRemoveOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + }; + + /** + * Create a new NewPatchRemoveOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + */ + constructor(resourceType: string, resourceId: string, path: string) { + this.payload = { resourceType, resourceId, path }; + } +} + +/** + * An ngrx action to add new JSON Patch REPLACE operation to state + */ +export class NewPatchReplaceOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + value: any + }; + + /** + * Create a new NewPatchReplaceOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + * @param value + * the operation's payload + */ + constructor(resourceType: string, resourceId: string, path: string, value: any) { + this.payload = { resourceType, resourceId, path, value }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ +export type PatchOperationsActions + = CommitPatchOperationsAction + | FlushPatchOperationsAction + | NewPatchAddOperationAction + | NewPatchCopyOperationAction + | NewPatchMoveOperationAction + | NewPatchRemoveOperationAction + | NewPatchReplaceOperationAction + | RollbacktPatchOperationsAction + | StartTransactionPatchOperationsAction diff --git a/src/app/core/json-patch/json-patch-operations.effects.spec.ts b/src/app/core/json-patch/json-patch-operations.effects.spec.ts new file mode 100644 index 0000000000..c0fa12cbf3 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.effects.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; + +import { cold, hot } from 'jasmine-marbles'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store } from '@ngrx/store'; +import { Observable, of as observableOf } from 'rxjs'; + +import { JsonPatchOperationsEffects } from './json-patch-operations.effects'; +import { JsonPatchOperationsState } from './json-patch-operations.reducer'; + +import { FlushPatchOperationsAction, JsonPatchOperationsActionTypes } from './json-patch-operations.actions'; + +describe('JsonPatchOperationsEffects test suite', () => { + let jsonPatchOperationsEffects: JsonPatchOperationsEffects; + let actions: Observable; + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + select: observableOf(true) + }); + const testJsonPatchResourceType = 'testResourceType'; + const testJsonPatchResourceId = 'testResourceId'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + JsonPatchOperationsEffects, + {provide: Store, useValue: store}, + provideMockActions(() => actions), + // other providers + ], + }); + + jsonPatchOperationsEffects = TestBed.get(JsonPatchOperationsEffects); + }); + + describe('commit$', () => { + it('should return a FLUSH_JSON_PATCH_OPERATIONS action in response to a COMMIT_JSON_PATCH_OPERATIONS action', () => { + actions = hot('--a-', { + a: { + type: JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS, + payload: {resourceType: testJsonPatchResourceType, resourceId: testJsonPatchResourceId} + } + }); + + const expected = cold('--b-', { + b: new FlushPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId) + }); + + expect(jsonPatchOperationsEffects.commit$).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/json-patch/json-patch-operations.effects.ts b/src/app/core/json-patch/json-patch-operations.effects.ts new file mode 100644 index 0000000000..3304db5b9e --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.effects.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +import { map } from 'rxjs/operators'; +import { Effect, Actions, ofType } from '@ngrx/effects'; + +import { + CommitPatchOperationsAction, FlushPatchOperationsAction, + JsonPatchOperationsActionTypes +} from './json-patch-operations.actions'; + +/** + * Provides effect methods for jsonPatch Operations actions + */ +@Injectable() +export class JsonPatchOperationsEffects { + + /** + * Dispatches a FlushPatchOperationsAction for every dispatched CommitPatchOperationsAction + */ + @Effect() commit$ = this.actions$.pipe( + ofType(JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS), + map((action: CommitPatchOperationsAction) => { + return new FlushPatchOperationsAction(action.payload.resourceType, action.payload.resourceId); + })); + + constructor(private actions$: Actions) {} + +} diff --git a/src/app/core/json-patch/json-patch-operations.reducer.spec.ts b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts new file mode 100644 index 0000000000..c6b21ce037 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts @@ -0,0 +1,326 @@ +import * as deepFreeze from 'deep-freeze'; + +import { + CommitPatchOperationsAction, + FlushPatchOperationsAction, + NewPatchAddOperationAction, + NewPatchRemoveOperationAction, + RollbacktPatchOperationsAction, + StartTransactionPatchOperationsAction +} from './json-patch-operations.actions'; +import { + JsonPatchOperationsEntry, + jsonPatchOperationsReducer, + JsonPatchOperationsResourceEntry, + JsonPatchOperationsState +} from './json-patch-operations.reducer'; + +class NullAction extends NewPatchAddOperationAction { + resourceType: string; + resourceId: string; + path: string; + value: any; + + constructor() { + super(null, null, null, null); + this.type = null; + } +} + +describe('jsonPatchOperationsReducer test suite', () => { + const testJsonPatchResourceType = 'testResourceType'; + const testJsonPatchResourceId = 'testResourceId'; + const testJsonPatchResourceAnotherId = 'testResourceAnotherId'; + const testJsonPatchResourcePath = '/testResourceType/testResourceId/testField'; + const testJsonPatchResourceValue = ['test']; + const patchOpBody = [{ + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }]; + const timestampBeforeStart = 1545994811991; + const timestampAfterStart = 1545994837492; + const startTimestamp = 1545994827492; + const testState: JsonPatchOperationsState = { + testResourceType: { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: null, + commitPending: false + } as JsonPatchOperationsResourceEntry + }; + + let initState: JsonPatchOperationsState; + + const anotherTestState: JsonPatchOperationsState = { + testResourceType: { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: null, + commitPending: false + } as JsonPatchOperationsResourceEntry + }; + deepFreeze(testState); + + beforeEach(() => { + spyOn(Date.prototype, 'getTime').and.callFake(() => { + return timestampBeforeStart; + }); + }); + + it('should start with an empty state', () => { + const action = new NullAction(); + const initialState = jsonPatchOperationsReducer(undefined, action); + + expect(initialState).toEqual(Object.create(null)); + }); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = jsonPatchOperationsReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + describe('When a new patch operation actions have been dispatched', () => { + + it('should return the properly state when it is empty', () => { + const action = new NewPatchAddOperationAction( + testJsonPatchResourceType, + testJsonPatchResourceId, + testJsonPatchResourcePath, + testJsonPatchResourceValue); + const newState = jsonPatchOperationsReducer(undefined, action); + + expect(newState).toEqual(testState); + }); + + it('should return the properly state when it is not empty', () => { + const action = new NewPatchRemoveOperationAction( + testJsonPatchResourceType, + testJsonPatchResourceId, + testJsonPatchResourcePath); + const newState = jsonPatchOperationsReducer(testState, action); + + expect(newState).toEqual(anotherTestState); + }); + }); + + describe('When StartTransactionPatchOperationsAction has been dispatched', () => { + it('should set \'transactionStartTime\' and \'commitPending\' to true', () => { + const action = new StartTransactionPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId, + startTimestamp); + const newState = jsonPatchOperationsReducer(testState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toEqual(startTimestamp); + expect(newState[testJsonPatchResourceType].commitPending).toBeTruthy(); + }); + }); + + describe('When CommitPatchOperationsAction has been dispatched', () => { + it('should set \'commitPending\' to false ', () => { + const action = new CommitPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + transactionStartTime: startTimestamp, + commitPending: true + }) + }); + const newState = jsonPatchOperationsReducer(initState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toEqual(startTimestamp); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + }); + }); + + describe('When RollbacktPatchOperationsAction has been dispatched', () => { + it('should set \'transactionStartTime\' to null and \'commitPending\' to false ', () => { + const action = new RollbacktPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + transactionStartTime: startTimestamp, + commitPending: true + }) + }); + const newState = jsonPatchOperationsReducer(initState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + }); + }); + + describe('When FlushPatchOperationsAction has been dispatched', () => { + + it('should flush only committed operations', () => { + const action = new FlushPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampAfterStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: startTimestamp, + commitPending: false + }) + }); + const newState = jsonPatchOperationsReducer(initState, action); + const expectedBody: any = [ + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampAfterStart + }, + ]; + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceId].body).toEqual(expectedBody); + }); + + beforeEach(() => { + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry, + testResourceAnotherId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceAnotherId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceAnotherId/testField' + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: startTimestamp, + commitPending: false + }) + }); + }); + + it('should flush committed operations for specified resource id', () => { + const action = new FlushPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + const newState = jsonPatchOperationsReducer(initState, action); + const expectedBody: any = [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceAnotherId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceAnotherId/testField' + }, + timeAdded: timestampBeforeStart + }, + ]; + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceId].body).toEqual([]); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceAnotherId].body).toEqual(expectedBody); + }); + + it('should flush operation list', () => { + const action = new FlushPatchOperationsAction(testJsonPatchResourceType, undefined); + const newState = jsonPatchOperationsReducer(initState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceId].body).toEqual([]); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceAnotherId].body).toEqual([]); + }); + + }); + +}); diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts new file mode 100644 index 0000000000..906d5e0331 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.reducer.ts @@ -0,0 +1,322 @@ +import { hasValue, isNotEmpty, isNotUndefined, isNull } from '../../shared/empty.util'; + +import { + FlushPatchOperationsAction, + PatchOperationsActions, + JsonPatchOperationsActionTypes, + NewPatchAddOperationAction, + NewPatchCopyOperationAction, + NewPatchMoveOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction, + CommitPatchOperationsAction, + StartTransactionPatchOperationsAction, + RollbacktPatchOperationsAction +} from './json-patch-operations.actions'; +import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model'; + +/** + * An interface to represent JSON-PATCH Operation objects to execute + */ +export interface JsonPatchOperationObject { + operation: JsonPatchOperationModel; + timeAdded: number; +} + +/** + * An interface to represent the body containing a list of JsonPatchOperationObject + */ +export interface JsonPatchOperationsEntry { + body: JsonPatchOperationObject[]; +} + +/** + * Interface used to represent a JSON-PATCH path member + * in JsonPatchOperationsState + */ +export interface JsonPatchOperationsResourceEntry { + children: { [resourceId: string]: JsonPatchOperationsEntry }; + transactionStartTime: number; + commitPending: boolean; +} + +/** + * The JSON patch operations State + * + * Consists of a map with a namespace as key, + * and an array of JsonPatchOperationModel as values + */ +export interface JsonPatchOperationsState { + [resourceType: string]: JsonPatchOperationsResourceEntry; +} + +const initialState: JsonPatchOperationsState = Object.create(null); + +/** + * The JSON-PATCH operations Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return JsonPatchOperationsState + * the new state + */ +export function jsonPatchOperationsReducer(state = initialState, action: PatchOperationsActions): JsonPatchOperationsState { + switch (action.type) { + + case JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS: { + return commitOperations(state, action as CommitPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS: { + return flushOperation(state, action as FlushPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: { + return newOperation(state, action as NewPatchAddOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_COPY_OPERATION: { + return newOperation(state, action as NewPatchCopyOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION: { + return newOperation(state, action as NewPatchMoveOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION: { + return newOperation(state, action as NewPatchRemoveOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION: { + return newOperation(state, action as NewPatchReplaceOperationAction); + } + + case JsonPatchOperationsActionTypes.ROLLBACK_JSON_PATCH_OPERATIONS: { + return rollbackOperations(state, action as RollbacktPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.START_TRANSACTION_JSON_PATCH_OPERATIONS: { + return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction); + } + + default: { + return state; + } + } +} + +/** + * Set the transaction start time. + * + * @param state + * the current state + * @param action + * an StartTransactionPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function startTransactionPatchOperations(state: JsonPatchOperationsState, action: StartTransactionPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && isNull(state[ action.payload.resourceType ].transactionStartTime)) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + transactionStartTime: action.payload.startTime, + commitPending: true + }) + }); + } else { + return state; + } +} + +/** + * Set commit pending state. + * + * @param state + * the current state + * @param action + * an CommitPatchOperationsAction + * @return JsonPatchOperationsState + * the new state, with the section new validity status. + */ +function commitOperations(state: JsonPatchOperationsState, action: CommitPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && state[ action.payload.resourceType ].commitPending) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + commitPending: false + }) + }); + } else { + return state; + } +} + +/** + * Set commit pending state. + * + * @param state + * the current state + * @param action + * an RollbacktPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && state[ action.payload.resourceType ].commitPending) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + transactionStartTime: null, + commitPending: false + }) + }); + } else { + return state; + } +} + +/** + * Add new JSON patch operation list. + * + * @param state + * the current state + * @param action + * an NewPatchAddOperationAction + * @return JsonPatchOperationsState + * the new state, with the section new validity status. + */ +function newOperation(state: JsonPatchOperationsState, action): JsonPatchOperationsState { + const newState = Object.assign({}, state); + const body: any[] = hasValidBody(newState, action.payload.resourceType, action.payload.resourceId) + ? newState[ action.payload.resourceType ].children[ action.payload.resourceId ].body : Array.of(); + const newBody = addOperationToList( + body, + action.type, + action.payload.path, + hasValue(action.payload.value) ? action.payload.value : null); + + if (hasValue(newState[ action.payload.resourceType ]) + && hasValue(newState[ action.payload.resourceType ].children)) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: Object.assign({}, state[ action.payload.resourceType ].children, { + [action.payload.resourceId]: { + body: newBody, + } + }), + commitPending: isNotUndefined(state[ action.payload.resourceType ].commitPending) ? state[ action.payload.resourceType ].commitPending : false + }) + }); + } else { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, { + children: { + [action.payload.resourceId]: { + body: newBody, + } + }, + transactionStartTime: null, + commitPending: false + }) + }); + } +} + +/** + * Check if state has a valid body. + * + * @param state + * the current state + * @param resourceType + * an resource type + * @param resourceId + * an resource ID + * @return boolean + */ +function hasValidBody(state: JsonPatchOperationsState, resourceType: any, resourceId: any): boolean { + return (hasValue(state[ resourceType ]) + && hasValue(state[ resourceType ].children) + && hasValue(state[ resourceType ].children[ resourceId ]) + && isNotEmpty(state[ resourceType ].children[ resourceId ].body)) +} + +/** + * Set the section validity. + * + * @param state + * the current state + * @param action + * an FlushPatchOperationsAction + * @return SubmissionObjectState + * the new state, with the section new validity status. + */ +function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ])) { + let newChildren; + if (isNotUndefined(action.payload.resourceId)) { + // flush only specified child's operations + if (hasValue(state[ action.payload.resourceType ].children) + && hasValue(state[ action.payload.resourceType ].children[ action.payload.resourceId ])) { + newChildren = Object.assign({}, state[ action.payload.resourceType ].children, { + [action.payload.resourceId]: { + body: state[ action.payload.resourceType ].children[ action.payload.resourceId ].body + .filter((entry) => entry.timeAdded > state[ action.payload.resourceType ].transactionStartTime) + } + }); + } else { + newChildren = state[ action.payload.resourceType ].children; + } + } else { + // flush all children's operations + newChildren = state[ action.payload.resourceType ].children; + Object.keys(newChildren) + .forEach((resourceId) => { + newChildren = Object.assign({}, newChildren, { + [resourceId]: { + body: newChildren[ resourceId ].body + .filter((entry) => entry.timeAdded > state[ action.payload.resourceType ].transactionStartTime) + } + }); + }) + } + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: newChildren, + transactionStartTime: null, + }) + }); + } else { + return state; + } +} + +function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?) { + const newBody = Array.from(body); + switch (actionType) { + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: + newBody.push(makeOperationEntry({ + op: JsonPatchOperationType.add, + path: targetPath, + value: value + })); + break; + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION: + newBody.push(makeOperationEntry({ + op: JsonPatchOperationType.replace, + path: targetPath, + value: value + })); + break; + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION: + newBody.push(makeOperationEntry({ op: JsonPatchOperationType.remove, path: targetPath })); + break; + } + return newBody; +} + +function makeOperationEntry(operation) { + return { operation: operation, timeAdded: new Date().getTime() }; +} diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts new file mode 100644 index 0000000000..4ecc215dc7 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -0,0 +1,253 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { Store, StoreModule } from '@ngrx/store'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { RequestService } from '../data/request.service'; +import { SubmissionPatchRequest } from '../data/request.models'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { JsonPatchOperationsService } from './json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../shared/submit-data-response-definition.model'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; +import { + CommitPatchOperationsAction, + RollbacktPatchOperationsAction, + StartTransactionPatchOperationsAction +} from './json-patch-operations.actions'; +import { MockStore } from '../../shared/testing/mock-store'; +import { RequestEntry } from '../data/request.reducer'; +import { catchError } from 'rxjs/operators'; + +class TestService extends JsonPatchOperationsService { + protected linkPath = ''; + protected patchRequestConstructor = SubmissionPatchRequest; + + constructor( + protected requestService: RequestService, + protected store: Store, + protected halService: HALEndpointService) { + + super(); + } +} + +describe('JsonPatchOperationsService test suite', () => { + let scheduler: TestScheduler; + let service: TestService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let halService: any; + let store: any; + + const timestamp = 1545994811991; + const timestampResponse = 1545994811992; + const mockState = { + 'json/patch': { + testResourceType: { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestamp + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: null, + commitPending: false + } as JsonPatchOperationsResourceEntry + } + }; + const resourceEndpointURL = 'https://rest.api/endpoint'; + const resourceEndpoint = 'resource'; + const resourceScope = '260'; + const resourceHref = resourceEndpointURL + '/' + resourceEndpoint + '/' + resourceScope; + + const testJsonPatchResourceType = 'testResourceType'; + const testJsonPatchResourceId = 'testResourceId'; + const patchOpBody = [{ + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }]; + + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, timeAdded: timestampResponse } as any + } as RequestEntry) + }; + + function initTestService(): TestService { + return new TestService( + requestService, + store, + halService + ); + + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], + providers: [ + { provide: Store, useClass: MockStore } + ] + }).compileComponents(); + })); + + beforeEach(() => { + store = TestBed.get(Store); + requestService = getMockRequestService(getRequestEntry$(true)); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + spyOn(store, 'select').and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType])); + spyOn(store, 'dispatch').and.callThrough(); + spyOn(Date.prototype, 'getTime').and.callFake(() => { + return timestamp; + }); + }); + + describe('jsonPatchByResourceType', () => { + + it('should call submitJsonPatchOperations method', () => { + spyOn((service as any), 'submitJsonPatchOperations').and.callThrough(); + + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpointURL, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect((service as any).submitJsonPatchOperations).toHaveBeenCalled(); + }); + + it('should configure a new SubmissionPatchRequest', () => { + const expected = new SubmissionPatchRequest(requestService.generateRequestId(), resourceHref, patchOpBody); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should dispatch a new StartTransactionPatchOperationsAction', () => { + const expectedAction = new StartTransactionPatchOperationsAction(testJsonPatchResourceType, undefined, timestamp); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + describe('when request is successful', () => { + it('should dispatch a new CommitPatchOperationsAction', () => { + const expectedAction = new CommitPatchOperationsAction(testJsonPatchResourceType, undefined); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + + describe('when request is not successful', () => { + beforeEach(() => { + store = TestBed.get(Store); + requestService = getMockRequestService(getRequestEntry$(false)); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + store.select.and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType])); + store.dispatch.and.callThrough(); + }); + + it('should dispatch a new RollbacktPatchOperationsAction', () => { + + const expectedAction = new RollbacktPatchOperationsAction(testJsonPatchResourceType, undefined); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType) + .pipe(catchError(() => observableOf({}))) + .subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + }); + + describe('jsonPatchByResourceID', () => { + + it('should call submitJsonPatchOperations method', () => { + spyOn((service as any), 'submitJsonPatchOperations').and.callThrough(); + + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpointURL, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect((service as any).submitJsonPatchOperations).toHaveBeenCalled(); + }); + + it('should configure a new SubmissionPatchRequest', () => { + const expected = new SubmissionPatchRequest(requestService.generateRequestId(), resourceHref, patchOpBody); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should dispatch a new StartTransactionPatchOperationsAction', () => { + const expectedAction = new StartTransactionPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId, timestamp); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + describe('when request is successful', () => { + it('should dispatch a new CommitPatchOperationsAction', () => { + const expectedAction = new CommitPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + + describe('when request is not successful', () => { + beforeEach(() => { + store = TestBed.get(Store); + requestService = getMockRequestService(getRequestEntry$(false)); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + store.select.and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType])); + store.dispatch.and.callThrough(); + }); + + it('should dispatch a new RollbacktPatchOperationsAction', () => { + + const expectedAction = new RollbacktPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId) + .pipe(catchError(() => observableOf({}))) + .subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + }); + +}); diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts new file mode 100644 index 0000000000..90eaf87a0e --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -0,0 +1,170 @@ +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, partition, take, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; + +import { hasValue, isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import { ErrorResponse, PostPatchSuccessResponse, RestResponse } from '../cache/response.models'; +import { PatchRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { CoreState } from '../core.reducers'; +import { jsonPatchOperationsByResourceType } from './selectors'; +import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; +import { + CommitPatchOperationsAction, + RollbacktPatchOperationsAction, + StartTransactionPatchOperationsAction +} from './json-patch-operations.actions'; +import { JsonPatchOperationModel } from './json-patch.model'; +import { getResponseFromEntry } from '../shared/operators'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; + +/** + * An abstract class that provides methods to make JSON Patch requests. + */ +export abstract class JsonPatchOperationsService { + + protected abstract requestService: RequestService; + protected abstract store: Store; + protected abstract linkPath: string; + protected abstract halService: HALEndpointService; + protected abstract patchRequestConstructor: any; + + /** + * Submit a new JSON Patch request with all operations stored in the state that are ready to be dispatched + * + * @param hrefObs + * Observable of request href + * @param resourceType + * The resource type value + * @param resourceId + * The resource id value + * @return Observable + * observable of response + */ + protected submitJsonPatchOperations(hrefObs: Observable, resourceType: string, resourceId?: string): Observable { + const requestId = this.requestService.generateRequestId(); + let startTransactionTime = null; + const [patchRequest$, emptyRequest$] = partition((request: PatchRequestDefinition) => isNotEmpty(request.body))(hrefObs.pipe( + flatMap((endpointURL: string) => { + return this.store.select(jsonPatchOperationsByResourceType(resourceType)).pipe( + take(1), + filter((operationsList: JsonPatchOperationsResourceEntry) => isUndefined(operationsList) || !(operationsList.commitPending)), + tap(() => startTransactionTime = new Date().getTime()), + map((operationsList: JsonPatchOperationsResourceEntry) => { + const body: JsonPatchOperationModel[] = []; + if (isNotEmpty(operationsList)) { + if (isNotEmpty(resourceId)) { + if (isNotUndefined(operationsList.children[resourceId]) && isNotEmpty(operationsList.children[resourceId].body)) { + operationsList.children[resourceId].body.forEach((entry) => { + body.push(entry.operation); + }); + } + } else { + Object.keys(operationsList.children) + .filter((key) => operationsList.children.hasOwnProperty(key)) + .filter((key) => hasValue(operationsList.children[key])) + .filter((key) => hasValue(operationsList.children[key].body)) + .forEach((key) => { + operationsList.children[key].body.forEach((entry) => { + body.push(entry.operation); + }); + }) + } + } + return this.getRequestInstance(requestId, endpointURL, body); + })); + }))); + + return observableMerge( + emptyRequest$.pipe( + filter((request: PatchRequestDefinition) => isEmpty(request.body)), + tap(() => startTransactionTime = null), + map(() => null)), + patchRequest$.pipe( + filter((request: PatchRequestDefinition) => isNotEmpty(request.body)), + tap(() => this.store.dispatch(new StartTransactionPatchOperationsAction(resourceType, resourceId, startTransactionTime))), + tap((request: PatchRequestDefinition) => this.requestService.configure(request)), + flatMap(() => { + const [successResponse$, errorResponse$] = partition((response: RestResponse) => response.isSuccessful)(this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + find((entry: ObjectCacheEntry) => startTransactionTime < entry.timeAdded), + map((entry: ObjectCacheEntry) => entry), + )); + return observableMerge( + errorResponse$.pipe( + tap(() => this.store.dispatch(new RollbacktPatchOperationsAction(resourceType, resourceId))), + flatMap((error: ErrorResponse) => observableThrowError(error))), + successResponse$.pipe( + filter((response: PostPatchSuccessResponse) => isNotEmpty(response)), + tap(() => this.store.dispatch(new CommitPatchOperationsAction(resourceType, resourceId))), + map((response: PostPatchSuccessResponse) => response.dataDefinition), + distinctUntilChanged())); + })) + ); + } + + /** + * Return an instance for RestRequest class + * + * @param uuid + * The request uuid + * @param href + * The request href + * @param body + * The request body + * @return Object + * instance of PatchRequestDefinition + */ + protected getRequestInstance(uuid: string, href: string, body?: any): PatchRequestDefinition { + return new this.patchRequestConstructor(uuid, href, body); + } + + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + /** + * Make a new JSON Patch request with all operations related to the specified resource type + * + * @param linkPath + * The link path of the request + * @param scopeId + * The scope id + * @param resourceType + * The resource type value + * @return Observable + * observable of response + */ + public jsonPatchByResourceType(linkPath: string, scopeId: string, resourceType: string): Observable { + const href$ = this.halService.getEndpoint(linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId))); + + return this.submitJsonPatchOperations(href$, resourceType); + } + + /** + * Make a new JSON Patch request with all operations related to the specified resource id + * + * @param linkPath + * The link path of the request + * @param scopeId + * The scope id + * @param resourceType + * The resource type value + * @param resourceId + * The resource id value + * @return Observable + * observable of response + */ + public jsonPatchByResourceID(linkPath: string, scopeId: string, resourceType: string, resourceId: string): Observable { + const hrefObs = this.halService.getEndpoint(linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId))); + + return this.submitJsonPatchOperations(hrefObs, resourceType, resourceId); + } +} diff --git a/src/app/core/json-patch/json-patch.model.ts b/src/app/core/json-patch/json-patch.model.ts new file mode 100644 index 0000000000..f855333fab --- /dev/null +++ b/src/app/core/json-patch/json-patch.model.ts @@ -0,0 +1,20 @@ +/** + * Represents all JSON Patch operations type. + */ +export enum JsonPatchOperationType { + test = 'test', + remove = 'remove', + add = 'add', + replace = 'replace', + move = 'move', + copy = 'copy', +} + +/** + * Represents a JSON Patch operations. + */ +export class JsonPatchOperationModel { + op: JsonPatchOperationType; + path: string; + value: any; +} diff --git a/src/app/core/json-patch/selectors.ts b/src/app/core/json-patch/selectors.ts new file mode 100644 index 0000000000..1ccde294de --- /dev/null +++ b/src/app/core/json-patch/selectors.ts @@ -0,0 +1,32 @@ +import { MemoizedSelector } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { coreSelector } from '../core.selectors'; +import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; +import { keySelector, subStateSelector } from '../../submission/selectors'; + +/** + * Return MemoizedSelector to select all jsonPatchOperations for a specified resource type, stored in the state + * + * @param resourceType + * the resource type + * @return MemoizedSelector + * MemoizedSelector + */ +export function jsonPatchOperationsByResourceType(resourceType: string): MemoizedSelector { + return keySelector(coreSelector,'json/patch', resourceType); +} + +/** + * Return MemoizedSelector to select all jsonPatchOperations for a specified resource id, stored in the state + * + * @param resourceType + * the resource type + * @param resourceId + * the resourceId type + * @return MemoizedSelector + * MemoizedSelector + */ +export function jsonPatchOperationsByResourceId(resourceType: string, resourceId: string): MemoizedSelector { + const resourceTypeSelector = jsonPatchOperationsByResourceType(resourceType); + return subStateSelector(resourceTypeSelector, resourceId); +} diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 78b204249b..cfb5a0751d 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -31,8 +31,13 @@ import { MockItem } from '../../shared/mocks/mock-item'; import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; import { BrowseService } from '../browse/browse.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { AuthService } from '../auth/auth.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; import { EmptyError } from 'rxjs/internal-compatibility'; -import { IndexState } from '../index/index.reducer'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { MetadataValue } from '../shared/metadata.models'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -61,13 +66,13 @@ describe('MetadataService', () => { let title: Title; let store: Store; - let indexStore: Store; let objectCacheService: ObjectCacheService; let requestService: RequestService; let uuidService: UUIDService; let remoteDataBuildService: RemoteDataBuildService; let itemDataService: ItemDataService; + let authService: AuthService; let location: Location; let router: Router; @@ -80,12 +85,11 @@ describe('MetadataService', () => { beforeEach(() => { store = new Store(undefined, undefined, undefined); - indexStore = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); objectCacheService = new ObjectCacheService(store); uuidService = new UUIDService(); - requestService = new RequestService(objectCacheService, uuidService, store, indexStore); + requestService = new RequestService(objectCacheService, uuidService, store, undefined); remoteDataBuildService = new RemoteDataBuildService(objectCacheService, requestService); TestBed.configureTestingModule({ @@ -112,7 +116,12 @@ describe('MetadataService', () => { { provide: RequestService, useValue: requestService }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, - { provide: HALEndpointService, useValue: {}}, + { provide: HALEndpointService, useValue: {} }, + { provide: AuthService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + { provide: NormalizedObjectBuildService, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, Meta, Title, ItemDataService, @@ -125,6 +134,7 @@ describe('MetadataService', () => { title = TestBed.get(Title); itemDataService = TestBed.get(ItemDataService); metadataService = TestBed.get(MetadataService); + authService = TestBed.get(AuthService); envConfig = TestBed.get(GLOBAL_CONFIG); @@ -143,7 +153,7 @@ describe('MetadataService', () => { expect(title.getTitle()).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane'); - expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26T19:58:25Z'); + expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26'); expect(tagStore.get('citation_issn')[0].content).toEqual('123456789'); expect(tagStore.get('citation_language')[0].content).toEqual('en'); expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3'); @@ -170,7 +180,7 @@ describe('MetadataService', () => { spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(MockItem)); router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.size).toBeGreaterThan(0) + expect(tagStore.size).toBeGreaterThan(0); router.navigate(['/other']); tick(); expect(tagStore.size).toEqual(2); @@ -203,26 +213,22 @@ describe('MetadataService', () => { undefined, MockItem )); - } + }; const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; - for (const metadatum of typedMockItem.metadata) { - if (metadatum.key === 'dc.type') { - metadatum.value = type; - break; - } - } + typedMockItem.metadata['dc.type'] = [ { value: type } ] as MetadataValue[]; return typedMockItem; - } + }; const mockPublisher = (mockItem: Item): Item => { const publishedMockItem = Object.assign(new Item(), mockItem) as Item; - publishedMockItem.metadata.push({ - key: 'dc.publisher', - language: 'en_US', - value: 'Mock Publisher' - }); + publishedMockItem.metadata['dc.publisher'] = [ + { + language: 'en_US', + value: 'Mock Publisher' + } + ] as MetadataValue[]; return publishedMockItem; } diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 136bfe8f3e..2b1cf4ffc1 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -58,7 +58,7 @@ export class MetadataService { map((route: ActivatedRoute) => { route = this.getCurrentRoute(route); return { params: route.params, data: route.data }; - }),).subscribe((routeInfo: any) => { + })).subscribe((routeInfo: any) => { this.processRouteChange(routeInfo); }); } @@ -66,7 +66,7 @@ export class MetadataService { public processRemoteData(remoteData: Observable>): void { remoteData.pipe(map((rd: RemoteData) => rd.payload), filter((co: CacheableObject) => hasValue(co)), - take(1),) + take(1)) .subscribe((dspaceObject: DSpaceObject) => { if (!this.initialized) { this.initialize(dspaceObject); @@ -80,7 +80,7 @@ export class MetadataService { this.clearMetaTags(); } if (routeInfo.data.value.title) { - this.translate.get(routeInfo.data.value.title).pipe(take(1)).subscribe((translatedTitle: string) => { + this.translate.get(routeInfo.data.value.title, routeInfo.data.value).pipe(take(1)).subscribe((translatedTitle: string) => { this.addMetaTag('title', translatedTitle); this.title.setTitle(translatedTitle); }); @@ -276,7 +276,7 @@ export class MetadataService { .subscribe((bitstreams: Bitstream[]) => { for (const bitstream of bitstreams) { bitstream.format.pipe( - take(1), + first(), catchError((error: Error) => { console.debug(error.message); return [] @@ -293,6 +293,10 @@ export class MetadataService { } } + private hasType(value: string): boolean { + return this.currentObject.value.hasMetadata('dc.type', { value: value, ignoreCase: true }); + } + /** * Returns true if this._item is a dissertation * @@ -300,14 +304,7 @@ export class MetadataService { * true if this._item has a dc.type equal to 'Thesis' */ private isDissertation(): boolean { - let isDissertation = false; - for (const metadatum of this.currentObject.value.metadata) { - if (metadatum.key === 'dc.type') { - isDissertation = metadatum.value.toLowerCase() === 'thesis'; - break; - } - } - return isDissertation; + return this.hasType('thesis'); } /** @@ -317,40 +314,15 @@ export class MetadataService { * true if this._item has a dc.type equal to 'Technical Report' */ private isTechReport(): boolean { - let isTechReport = false; - for (const metadatum of this.currentObject.value.metadata) { - if (metadatum.key === 'dc.type') { - isTechReport = metadatum.value.toLowerCase() === 'technical report'; - break; - } - } - return isTechReport; + return this.hasType('technical report'); } private getMetaTagValue(key: string): string { - let value: string; - for (const metadatum of this.currentObject.value.metadata) { - if (metadatum.key === key) { - value = metadatum.value; - } - } - return value; + return this.currentObject.value.firstMetadataValue(key); } private getFirstMetaTagValue(keys: string[]): string { - let value: string; - for (const metadatum of this.currentObject.value.metadata) { - for (const key of keys) { - if (key === metadatum.key) { - value = metadatum.value; - break; - } - } - if (value !== undefined) { - break; - } - } - return value; + return this.currentObject.value.firstMetadataValue(keys); } private getMetaTagValuesAndCombine(key: string): string { @@ -358,15 +330,7 @@ export class MetadataService { } private getMetaTagValues(keys: string[]): string[] { - const values: string[] = []; - for (const metadatum of this.currentObject.value.metadata) { - for (const key of keys) { - if (key === metadatum.key) { - values.push(metadatum.value); - } - } - } - return values; + return this.currentObject.value.allMetadataValues(keys); } private addMetaTag(property: string, content: string): void { diff --git a/src/app/core/metadata/metadatafield.model.ts b/src/app/core/metadata/metadatafield.model.ts index 77cecb927e..ba28b59d0e 100644 --- a/src/app/core/metadata/metadatafield.model.ts +++ b/src/app/core/metadata/metadatafield.model.ts @@ -1,8 +1,12 @@ import { MetadataSchema } from './metadataschema.model'; import { autoserialize } from 'cerialize'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { isNotEmpty } from '../../shared/empty.util'; export class MetadataField implements ListableObject { + @autoserialize + id: number; + @autoserialize self: string; @@ -17,4 +21,12 @@ export class MetadataField implements ListableObject { @autoserialize schema: MetadataSchema; + + toString(separator: string = '.'): string { + let key = this.schema.prefix + separator + this.element; + if (isNotEmpty(this.qualifier)) { + key += separator + this.qualifier; + } + return key; + } } diff --git a/src/app/core/metadata/normalized-metadata-schema.model.ts b/src/app/core/metadata/normalized-metadata-schema.model.ts new file mode 100644 index 0000000000..c121938940 --- /dev/null +++ b/src/app/core/metadata/normalized-metadata-schema.model.ts @@ -0,0 +1,35 @@ +import { autoserialize } from 'cerialize'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { mapsTo } from '../cache/builders/build-decorators'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { MetadataSchema } from './metadataschema.model'; + +/** + * Normalized class for a DSpace MetadataSchema + */ +@mapsTo(MetadataSchema) +export class NormalizedMetadataSchema extends NormalizedObject implements ListableObject { + /** + * The unique identifier for this schema + */ + @autoserialize + id: number; + + /** + * The REST link to itself + */ + @autoserialize + self: string; + + /** + * A unique prefix that defines this schema + */ + @autoserialize + prefix: string; + + /** + * The namespace for this schema + */ + @autoserialize + namespace: string; +} diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index c87597cffc..8274ceef60 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -5,7 +5,7 @@ import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { RequestEntry } from '../data/request.reducer'; import { RemoteData } from '../data/remote-data'; import { PageInfo } from '../shared/page-info.model'; @@ -14,13 +14,33 @@ import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse + RegistryMetadataschemasSuccessResponse, + RestResponse } from '../cache/response.models'; import { Component } from '@angular/core'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; import { map } from 'rxjs/operators'; +import { Store, StoreModule } from '@ngrx/store'; +import { MockStore } from '../../shared/testing/mock-store'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { + MetadataRegistryCancelFieldAction, + MetadataRegistryCancelSchemaAction, + MetadataRegistryDeselectAllFieldAction, + MetadataRegistryDeselectAllSchemaAction, + MetadataRegistryDeselectFieldAction, + MetadataRegistryDeselectSchemaAction, + MetadataRegistryEditFieldAction, + MetadataRegistryEditSchemaAction, + MetadataRegistrySelectFieldAction, + MetadataRegistrySelectSchemaAction +} from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions'; +import { MetadataSchema } from '../metadata/metadataschema.model'; +import { MetadataField } from '../metadata/metadatafield.model'; @Component({ template: '' }) class DummyComponent { @@ -28,6 +48,7 @@ class DummyComponent { describe('RegistryService', () => { let registryService: RegistryService; + let mockStore; const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'registry-service-spec-pagination', pageSize: 20 @@ -49,68 +70,38 @@ describe('RegistryService', () => { ]; const mockFieldsList = [ { + id: 1, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8', element: 'contributor', qualifier: 'advisor', - scopenote: null, + scopeNote: null, schema: mockSchemasList[0] }, { + id: 2, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9', element: 'contributor', qualifier: 'author', - scopenote: null, + scopeNote: null, schema: mockSchemasList[0] }, { + id: 3, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10', element: 'contributor', qualifier: 'editor', - scopenote: 'test scope note', + scopeNote: 'test scope note', schema: mockSchemasList[1] }, { + id: 4, self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11', element: 'contributor', qualifier: 'illustrator', - scopenote: null, + scopeNote: null, schema: mockSchemasList[1] } ]; - const mockFormatsList = [ - { - shortDescription: 'Unknown', - description: 'Unknown data format', - mimetype: 'application/octet-stream', - supportLevel: 0, - internal: false, - extensions: null - }, - { - shortDescription: 'License', - description: 'Item-specific license agreed upon to submission', - mimetype: 'text/plain; charset=utf-8', - supportLevel: 1, - internal: true, - extensions: null - }, - { - shortDescription: 'CC License', - description: 'Item-specific Creative Commons license agreed upon to submission', - mimetype: 'text/html; charset=utf-8', - supportLevel: 2, - internal: true, - extensions: null - }, - { - shortDescription: 'Adobe PDF', - description: 'Adobe Portable Document Format', - mimetype: 'application/pdf', - supportLevel: 0, - internal: false, - extensions: null - } - ]; const pageInfo = new PageInfo(); pageInfo.elementsPerPage = 20; @@ -118,6 +109,7 @@ describe('RegistryService', () => { const endpoint = 'path'; const endpointWithParams = `${endpoint}?size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`; + const fieldEndpointWithParams = `${endpoint}?schema=${mockSchemasList[0].prefix}&size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`; const halServiceStub = { getEndpoint: (link: string) => observableOf(endpoint) @@ -138,7 +130,7 @@ describe('RegistryService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [CommonModule], + imports: [CommonModule, StoreModule.forRoot({}), TranslateModule.forRoot()], declarations: [ DummyComponent ], @@ -146,11 +138,13 @@ describe('RegistryService', () => { { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: rdbStub }, { provide: HALEndpointService, useValue: halServiceStub }, + { provide: Store, useClass: MockStore }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, RegistryService ] }); registryService = TestBed.get(RegistryService); - + mockStore = TestBed.get(Store); spyOn((registryService as any).halService, 'getEndpoint').and.returnValue(observableOf(endpoint)); }); @@ -159,7 +153,7 @@ describe('RegistryService', () => { metadataschemas: mockSchemasList, page: pageInfo }); - const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { @@ -188,7 +182,7 @@ describe('RegistryService', () => { metadataschemas: mockSchemasList, page: pageInfo }); - const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { @@ -217,7 +211,7 @@ describe('RegistryService', () => { metadatafields: mockFieldsList, page: pageInfo }); - const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { @@ -237,7 +231,7 @@ describe('RegistryService', () => { }); it('should call getByHref on the request service with the correct request url', () => { - expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); + expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(fieldEndpointWithParams); }); }); @@ -246,7 +240,7 @@ describe('RegistryService', () => { bitstreamformats: mockFieldsList, page: pageInfo }); - const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { @@ -269,4 +263,186 @@ describe('RegistryService', () => { expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); }); }); + + describe('when dispatching to the store', () => { + beforeEach(() => { + spyOn(mockStore, 'dispatch'); + }); + + describe('when calling editMetadataSchema', () => { + beforeEach(() => { + registryService.editMetadataSchema(mockSchemasList[0]); + }); + + it('should dispatch a MetadataRegistryEditSchemaAction with the correct schema', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditSchemaAction(mockSchemasList[0])); + }) + }); + + describe('when calling cancelEditMetadataSchema', () => { + beforeEach(() => { + registryService.cancelEditMetadataSchema(); + }); + + it('should dispatch a MetadataRegistryCancelSchemaAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelSchemaAction()); + }) + }); + + describe('when calling selectMetadataSchema', () => { + beforeEach(() => { + registryService.selectMetadataSchema(mockSchemasList[0]); + }); + + it('should dispatch a MetadataRegistrySelectSchemaAction with the correct schema', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectSchemaAction(mockSchemasList[0])); + }) + }); + + describe('when calling deselectMetadataSchema', () => { + beforeEach(() => { + registryService.deselectMetadataSchema(mockSchemasList[0]); + }); + + it('should dispatch a MetadataRegistryDeselectSchemaAction with the correct schema', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectSchemaAction(mockSchemasList[0])); + }) + }); + + describe('when calling deselectAllMetadataSchema', () => { + beforeEach(() => { + registryService.deselectAllMetadataSchema(); + }); + + it('should dispatch a MetadataRegistryDeselectAllSchemaAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllSchemaAction()); + }) + }); + + describe('when calling editMetadataField', () => { + beforeEach(() => { + registryService.editMetadataField(mockFieldsList[0]); + }); + + it('should dispatch a MetadataRegistryEditFieldAction with the correct Field', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditFieldAction(mockFieldsList[0])); + }) + }); + + describe('when calling cancelEditMetadataField', () => { + beforeEach(() => { + registryService.cancelEditMetadataField(); + }); + + it('should dispatch a MetadataRegistryCancelFieldAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelFieldAction()); + }) + }); + + describe('when calling selectMetadataField', () => { + beforeEach(() => { + registryService.selectMetadataField(mockFieldsList[0]); + }); + + it('should dispatch a MetadataRegistrySelectFieldAction with the correct Field', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectFieldAction(mockFieldsList[0])); + }) + }); + + describe('when calling deselectMetadataField', () => { + beforeEach(() => { + registryService.deselectMetadataField(mockFieldsList[0]); + }); + + it('should dispatch a MetadataRegistryDeselectFieldAction with the correct Field', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectFieldAction(mockFieldsList[0])); + }) + }); + + describe('when calling deselectAllMetadataField', () => { + beforeEach(() => { + registryService.deselectAllMetadataField(); + }); + + it('should dispatch a MetadataRegistryDeselectAllFieldAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllFieldAction()); + }) + }); + }); + + describe('when createOrUpdateMetadataSchema is called', () => { + let result: Observable; + + beforeEach(() => { + result = registryService.createOrUpdateMetadataSchema(mockSchemasList[0]); + }); + + it('should return the created/updated metadata schema', () => { + result.subscribe((schema: MetadataSchema) => { + expect(schema).toEqual(mockSchemasList[0]); + }); + }); + }); + + describe('when createOrUpdateMetadataField is called', () => { + let result: Observable; + + beforeEach(() => { + result = registryService.createOrUpdateMetadataField(mockFieldsList[0]); + }); + + it('should return the created/updated metadata field', () => { + result.subscribe((field: MetadataField) => { + expect(field).toEqual(mockFieldsList[0]); + }); + }); + }); + + describe('when deleteMetadataSchema is called', () => { + let result: Observable; + + beforeEach(() => { + result = registryService.deleteMetadataSchema(mockSchemasList[0].id); + }); + + it('should return a successful response', () => { + result.subscribe((response: RestResponse) => { + expect(response.isSuccessful).toBe(true); + }); + }) + }); + + describe('when deleteMetadataField is called', () => { + let result: Observable; + + beforeEach(() => { + result = registryService.deleteMetadataField(mockFieldsList[0].id); + }); + + it('should return a successful response', () => { + result.subscribe((response: RestResponse) => { + expect(response.isSuccessful).toBe(true); + }); + }) + }); + + describe('when clearMetadataSchemaRequests is called', () => { + beforeEach(() => { + registryService.clearMetadataSchemaRequests().subscribe(); + }); + + it('should remove the requests related to metadata schemas from cache', () => { + expect((registryService as any).requestService.removeByHrefSubstring).toHaveBeenCalled(); + }); + }); + + describe('when clearMetadataFieldRequests is called', () => { + beforeEach(() => { + registryService.clearMetadataFieldRequests().subscribe(); + }); + + it('should remove the requests related to metadata fields from cache', () => { + expect((registryService as any).requestService.removeByHrefSubstring).toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index ef92d42ce9..137b4c3a87 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -6,8 +6,14 @@ import { PageInfo } from '../shared/page-info.model'; import { MetadataSchema } from '../metadata/metadataschema.model'; import { MetadataField } from '../metadata/metadatafield.model'; import { BitstreamFormat } from './mock-bitstream-format.model'; -import { filter, flatMap, map, tap } from 'rxjs/operators'; -import { GetRequest, RestRequest } from '../data/request.models'; +import { + CreateMetadataFieldRequest, + CreateMetadataSchemaRequest, + DeleteRequest, + GetRequest, + RestRequest, UpdateMetadataFieldRequest, + UpdateMetadataSchemaRequest +} from '../data/request.models'; import { GenericConstructor } from '../shared/generic-constructor'; import { ResponseParsingService } from '../data/parsing.service'; import { RegistryMetadataschemasResponseParsingService } from '../data/registry-metadataschemas-response-parsing.service'; @@ -15,20 +21,55 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { RequestService } from '../data/request.service'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; import { + ErrorResponse, MetadatafieldSuccessResponse, MetadataschemaSuccessResponse, RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse + RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, hasNoValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service'; import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; -import { RequestEntry } from '../data/request.reducer'; -import { getResponseFromEntry } from '../shared/operators'; +import { + configureRequest, + getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; +import { createSelector, select, Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers'; +import { + MetadataRegistryCancelFieldAction, + MetadataRegistryCancelSchemaAction, + MetadataRegistryDeselectAllFieldAction, + MetadataRegistryDeselectAllSchemaAction, + MetadataRegistryDeselectFieldAction, + MetadataRegistryDeselectSchemaAction, + MetadataRegistryEditFieldAction, + MetadataRegistryEditSchemaAction, + MetadataRegistrySelectFieldAction, + MetadataRegistrySelectSchemaAction +} from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions'; +import { distinctUntilChanged, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { ResourceType } from '../shared/resource-type'; +import { NormalizedMetadataSchema } from '../metadata/normalized-metadata-schema.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { HttpHeaders } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; + +const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry; +const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema); +const selectedMetadataSchemasSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.selectedSchemas); +const editMetadataFieldSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editField); +const selectedMetadataFieldsSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.selectedFields); @Injectable() export class RegistryService { @@ -39,7 +80,10 @@ export class RegistryService { constructor(protected requestService: RequestService, private rdb: RemoteDataBuildService, - private halService: HALEndpointService) { + private halService: HALEndpointService, + private store: Store, + private notificationsService: NotificationsService, + private translateService: TranslateService) { } @@ -99,6 +143,46 @@ export class RegistryService { } public getMetadataFieldsBySchema(schema: MetadataSchema, pagination: PaginationComponentOptions): Observable>> { + const requestObs = this.getMetadataFieldsBySchemaRequestObs(pagination, schema); + + const requestEntryObs = requestObs.pipe( + flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) + ); + + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse) + ); + + const metadatafieldsObs: Observable = rmrObs.pipe( + map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields) + ); + + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + + map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo) + ); + + const payloadObs = observableCombineLatest(metadatafieldsObs, pageInfoObs).pipe( + map(([metadatafields, pageInfo]) => { + return new PaginatedList(pageInfo, metadatafields); + }) + ); + + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + } + + /** + * Retrieve all existing metadata fields as a paginated list + * @param pagination Pagination options to determine which page of metadata fields should be requested + * When no pagination is provided, all metadata fields are requested in one large page + * @returns an observable that emits a remote data object with a page of metadata fields + */ + public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable>> { + if (hasNoValue(pagination)) { + pagination = { currentPage: 1, pageSize: 10000 } as any; + } const requestObs = this.getMetadataFieldsRequestObs(pagination); const requestEntryObs = requestObs.pipe( @@ -112,7 +196,8 @@ export class RegistryService { const metadatafieldsObs: Observable = rmrObs.pipe( map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields), - map((metadataFields: MetadataField[]) => metadataFields.filter((field) => field.schema.id === schema.id)) + /* Make sure to explicitly cast this into a MetadataField object, on first page loads this object comes from the object cache created by the server and its prototype is unknown */ + map((metadataFields: MetadataField[]) => metadataFields.map((metadataField: MetadataField) => Object.assign(new MetadataField(), metadataField))) ); const pageInfoObs: Observable = requestEntryObs.pipe( @@ -160,7 +245,7 @@ export class RegistryService { return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } - private getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable { + public getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable { return this.halService.getEndpoint(this.metadataSchemasPath).pipe( map((url: string) => { const args: string[] = []; @@ -180,6 +265,28 @@ export class RegistryService { ); } + private getMetadataFieldsBySchemaRequestObs(pagination: PaginationComponentOptions, schema: MetadataSchema): Observable { + return this.halService.getEndpoint(this.metadataFieldsPath + '/search/bySchema').pipe( + // return this.halService.getEndpoint(this.metadataFieldsPath).pipe( + map((url: string) => { + const args: string[] = []; + args.push(`schema=${schema.prefix}`); + args.push(`size=${pagination.pageSize}`); + args.push(`page=${pagination.currentPage - 1}`); + if (isNotEmpty(args)) { + url = new URLCombiner(url, `?${args.join('&')}`).toString(); + } + const request = new GetRequest(this.requestService.generateRequestId(), url); + return Object.assign(request, { + getResponseParser(): GenericConstructor { + return RegistryMetadatafieldsResponseParsingService; + } + }); + }), + tap((request: RestRequest) => this.requestService.configure(request)), + ); + } + private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable { return this.halService.getEndpoint(this.metadataFieldsPath).pipe( map((url: string) => { @@ -220,4 +327,257 @@ export class RegistryService { ); } + public editMetadataSchema(schema: MetadataSchema) { + this.store.dispatch(new MetadataRegistryEditSchemaAction(schema)); + } + + public cancelEditMetadataSchema() { + this.store.dispatch(new MetadataRegistryCancelSchemaAction()); + } + + public getActiveMetadataSchema(): Observable { + return this.store.pipe(select(editMetadataSchemaSelector)); + } + + public selectMetadataSchema(schema: MetadataSchema) { + this.store.dispatch(new MetadataRegistrySelectSchemaAction(schema)) + } + + public deselectMetadataSchema(schema: MetadataSchema) { + this.store.dispatch(new MetadataRegistryDeselectSchemaAction(schema)) + } + + public deselectAllMetadataSchema() { + this.store.dispatch(new MetadataRegistryDeselectAllSchemaAction()) + } + + public getSelectedMetadataSchemas(): Observable { + return this.store.pipe(select(selectedMetadataSchemasSelector)); + } + + public editMetadataField(field: MetadataField) { + this.store.dispatch(new MetadataRegistryEditFieldAction(field)); + } + + public cancelEditMetadataField() { + this.store.dispatch(new MetadataRegistryCancelFieldAction()); + } + + public getActiveMetadataField(): Observable { + return this.store.pipe(select(editMetadataFieldSelector)); + } + + public selectMetadataField(field: MetadataField) { + this.store.dispatch(new MetadataRegistrySelectFieldAction(field)) + } + + public deselectMetadataField(field: MetadataField) { + this.store.dispatch(new MetadataRegistryDeselectFieldAction(field)) + } + + public deselectAllMetadataField() { + this.store.dispatch(new MetadataRegistryDeselectAllFieldAction()) + } + + public getSelectedMetadataFields(): Observable { + return this.store.pipe(select(selectedMetadataFieldsSelector)); + } + + /** + * Create or Update a MetadataSchema + * If the MetadataSchema contains an id, it is assumed the schema already exists and is updated instead + * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint): + * - On creation, a CreateMetadataSchemaRequest is used + * - On update, a UpdateMetadataSchemaRequest is used + * @param schema The MetadataSchema to create or update + */ + public createOrUpdateMetadataSchema(schema: MetadataSchema): Observable { + const isUpdate = hasValue(schema.id); + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.halService.getEndpoint(this.metadataSchemasPath).pipe( + isNotEmptyOperator(), + map((endpoint: string) => (isUpdate ? `${endpoint}/${schema.id}` : endpoint)), + distinctUntilChanged() + ); + + const serializedSchema = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(ResourceType.MetadataSchema)).serialize(schema as NormalizedMetadataSchema); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => { + if (isUpdate) { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new UpdateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema), options); + } else { + return new CreateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema)); + } + }) + ); + + // Execute the post/put request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Return created/updated schema + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful) { + if (hasValue((response as any).errorMessage)) { + this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1)); + } + } else { + this.showNotifications(true, isUpdate, false, { prefix: schema.prefix }); + return response; + } + }), + isNotEmptyOperator(), + map((response: MetadataschemaSuccessResponse) => { + if (isNotEmpty(response.metadataschema)) { + return response.metadataschema; + } + }) + ); + } + + public deleteMetadataSchema(id: number): Observable { + return this.delete(this.metadataSchemasPath, id); + } + + public clearMetadataSchemaRequests(): Observable { + return this.halService.getEndpoint(this.metadataSchemasPath).pipe( + tap((href: string) => this.requestService.removeByHrefSubstring(href)) + ) + } + + /** + * Create or Update a MetadataField + * If the MetadataField contains an id, it is assumed the field already exists and is updated instead + * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint): + * - On creation, a CreateMetadataFieldRequest is used + * - On update, a UpdateMetadataFieldRequest is used + * @param field The MetadataField to create or update + */ + public createOrUpdateMetadataField(field: MetadataField): Observable { + const isUpdate = hasValue(field.id); + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.halService.getEndpoint(this.metadataFieldsPath).pipe( + isNotEmptyOperator(), + map((endpoint: string) => (isUpdate ? `${endpoint}/${field.id}` : `${endpoint}?schemaId=${field.schema.id}`)), + distinctUntilChanged() + ); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => { + if (isUpdate) { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new UpdateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field), options); + } else { + return new CreateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field)); + } + }) + ); + + // Execute the post/put request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Return created/updated field + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful) { + if (hasValue((response as any).errorMessage)) { + this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1)); + } + } else { + const fieldString = `${field.schema.prefix}.${field.element}${field.qualifier ? `.${field.qualifier}` : ''}`; + this.showNotifications(true, isUpdate, true, { field: fieldString }); + return response; + } + }), + isNotEmptyOperator(), + map((response: MetadatafieldSuccessResponse) => { + if (isNotEmpty(response.metadatafield)) { + return response.metadatafield; + } + }) + ); + } + + public deleteMetadataField(id: number): Observable { + return this.delete(this.metadataFieldsPath, id); + } + + public clearMetadataFieldRequests(): Observable { + return this.halService.getEndpoint(this.metadataFieldsPath).pipe( + tap((href: string) => this.requestService.removeByHrefSubstring(href)) + ) + } + + private delete(path: string, id: number): Observable { + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.halService.getEndpoint(path).pipe( + isNotEmptyOperator(), + map((endpoint: string) => `${endpoint}/${id}`), + distinctUntilChanged() + ); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => new DeleteRequest(requestId, endpoint)) + ); + + // Execute the delete request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + } + + private showNotifications(success: boolean, edited: boolean, isField: boolean, options: any) { + const prefix = 'admin.registries.schema.notification'; + const suffix = success ? 'success' : 'failure'; + const editedString = edited ? 'edited' : 'created'; + const messages = observableCombineLatest( + this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), + this.translateService.get(`${prefix}${isField ? '.field' : ''}.${editedString}`, options) + ); + messages.subscribe(([head, content]) => { + if (success) { + this.notificationsService.success(head, content) + } else { + this.notificationsService.error(head, content) + } + }); + } + + /** + * Retrieve a filtered paginated list of metadata fields + * @param query {string} The query to filter the field names by + * @returns an observable that emits a remote data object with a page of metadata fields that match the query + */ + queryMetadataFields(query: string): Observable>> { + return this.getAllMetadataFields().pipe( + map((rd: RemoteData>) => { + const filteredFields: MetadataField[] = rd.payload.page.filter( + (field: MetadataField) => field.toString().indexOf(query) >= 0 + ); + const page: PaginatedList = new PaginatedList(new PageInfo(), filteredFields) + return Object.assign({}, rd, { payload: page }); + }) + ); + } } diff --git a/src/app/core/roles/role-types.ts b/src/app/core/roles/role-types.ts new file mode 100644 index 0000000000..b39d1205a6 --- /dev/null +++ b/src/app/core/roles/role-types.ts @@ -0,0 +1,5 @@ +export enum RoleType { + Submitter = 'submitter', + Controller = 'controller', + Admin = 'admin' +} diff --git a/src/app/core/roles/role.service.ts b/src/app/core/roles/role.service.ts new file mode 100644 index 0000000000..7a4b6e6ccf --- /dev/null +++ b/src/app/core/roles/role.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; + +import { RoleType } from './role-types'; +import { CollectionDataService } from '../data/collection-data.service'; + +/** + * A service that provides methods to identify user role. + */ +@Injectable() +export class RoleService { + + /** + * Initialize instance variables + * + * @param {CollectionDataService} collectionService + */ + constructor(private collectionService: CollectionDataService) { + } + + /** + * Check if current user is a submitter + */ + isSubmitter(): Observable { + return this.collectionService.hasAuthorizedCollection().pipe( + distinctUntilChanged() + ); + } + + /** + * Check if current user is a controller + */ + isController(): Observable { + // TODO find a way to check if user is a controller + return observableOf(true); + } + + /** + * Check if current user is an admin + */ + isAdmin(): Observable { + // TODO find a way to check if user is an admin + return observableOf(false); + } + + /** + * Check if current user by role type + * + * @param {RoleType} role + * the role type + */ + checkRole(role: RoleType): Observable { + let check: Observable; + switch (role) { + case RoleType.Submitter: + check = this.isSubmitter(); + break; + case RoleType.Controller: + check = this.isController(); + break; + case RoleType.Admin: + check = this.isAdmin(); + break; + } + + return check; + } +} diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index 8fdc14bd6e..0471d1fbbb 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -3,6 +3,9 @@ import { Bitstream } from './bitstream.model'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; +import { License } from './license.model'; +import { ResourcePolicy } from './resource-policy.model'; +import { PaginatedList } from '../data/paginated-list'; export class Collection extends DSpaceObject { @@ -16,7 +19,7 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.description */ get introductoryText(): string { - return this.findMetadata('dc.description'); + return this.firstMetadataValue('dc.description'); } /** @@ -24,7 +27,7 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.description.abstract */ get shortDescription(): string { - return this.findMetadata('dc.description.abstract'); + return this.firstMetadataValue('dc.description.abstract'); } /** @@ -32,15 +35,15 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.rights */ get copyrightText(): string { - return this.findMetadata('dc.rights'); + return this.firstMetadataValue('dc.rights'); } /** * The license of this Collection * Corresponds to the metadata field dc.rights.license */ - get license(): string { - return this.findMetadata('dc.rights.license'); + get dcLicense(): string { + return this.firstMetadataValue('dc.rights.license'); } /** @@ -48,14 +51,24 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.description.tableofcontents */ get sidebarText(): string { - return this.findMetadata('dc.description.tableofcontents'); + return this.firstMetadataValue('dc.description.tableofcontents'); } + /** + * The deposit license of this Collection + */ + license: Observable>; + /** * The Bitstream that represents the logo of this Collection */ logo: Observable>; + /** + * The default access conditions of this Collection + */ + defaultAccessConditions: Observable>>; + /** * An array of Collections that are direct parents of this Collection */ diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index c5f6e0ab87..c4e703fd7f 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -17,7 +17,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.description */ get introductoryText(): string { - return this.findMetadata('dc.description'); + return this.firstMetadataValue('dc.description'); } /** @@ -25,7 +25,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.description.abstract */ get shortDescription(): string { - return this.findMetadata('dc.description.abstract'); + return this.firstMetadataValue('dc.description.abstract'); } /** @@ -33,7 +33,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.rights */ get copyrightText(): string { - return this.findMetadata('dc.rights'); + return this.firstMetadataValue('dc.rights'); } /** @@ -41,7 +41,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.description.tableofcontents */ get sidebarText(): string { - return this.findMetadata('dc.description.tableofcontents'); + return this.firstMetadataValue('dc.description.tableofcontents'); } /** diff --git a/src/app/core/shared/config/config-authority.model.ts b/src/app/core/shared/config/config-authority.model.ts deleted file mode 100644 index bbb8605bcc..0000000000 --- a/src/app/core/shared/config/config-authority.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { ConfigObject } from './config.model'; -import { SubmissionSectionModel } from './config-submission-section.model'; - -@inheritSerialization(ConfigObject) -export class ConfigAuthorityModel extends ConfigObject { - - @autoserialize - id: string; - - @autoserialize - display: string; - - @autoserialize - value: string; - - @autoserialize - otherInformation: any; - - @autoserialize - language: string; - -} diff --git a/src/app/core/shared/config/config-object-factory.ts b/src/app/core/shared/config/config-object-factory.ts deleted file mode 100644 index 4cb5016983..0000000000 --- a/src/app/core/shared/config/config-object-factory.ts +++ /dev/null @@ -1,34 +0,0 @@ - -import { GenericConstructor } from '../../shared/generic-constructor'; - -import { SubmissionSectionModel } from './config-submission-section.model'; -import { SubmissionFormsModel } from './config-submission-forms.model'; -import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; -import { ConfigType } from './config-type'; -import { ConfigObject } from './config.model'; -import { ConfigAuthorityModel } from './config-authority.model'; - -export class ConfigObjectFactory { - public static getConstructor(type): GenericConstructor { - switch (type) { - case ConfigType.SubmissionDefinition: - case ConfigType.SubmissionDefinitions: { - return SubmissionDefinitionsModel - } - case ConfigType.SubmissionForm: - case ConfigType.SubmissionForms: { - return SubmissionFormsModel - } - case ConfigType.SubmissionSection: - case ConfigType.SubmissionSections: { - return SubmissionSectionModel - } - case ConfigType.Authority: { - return ConfigAuthorityModel - } - default: { - return undefined; - } - } - } -} diff --git a/src/app/core/shared/config/config-submission-section.model.ts b/src/app/core/shared/config/config-submission-section.model.ts deleted file mode 100644 index 0eb9daaeab..0000000000 --- a/src/app/core/shared/config/config-submission-section.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { ConfigObject } from './config.model'; - -@inheritSerialization(ConfigObject) -export class SubmissionSectionModel extends ConfigObject { - - @autoserialize - header: string; - - @autoserialize - mandatory: boolean; - - @autoserialize - sectionType: string; - - @autoserialize - visibility: { - main: any, - other: any - } - -} diff --git a/src/app/core/shared/config/config.model.ts b/src/app/core/shared/config/config.model.ts deleted file mode 100644 index 8d86f317e1..0000000000 --- a/src/app/core/shared/config/config.model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; - -export abstract class ConfigObject { - - @autoserialize - public name: string; - - @autoserialize - public type: string; - - @autoserialize - public _links: { - [name: string]: string - } - - /** - * The link to the rest endpoint where this config object can be found - */ - @autoserialize - self: string; -} diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 68338143ba..063398b339 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,29 +1,31 @@ -import { Metadatum } from './metadatum.model' -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { Observable } from 'rxjs'; + +import { MetadataMap, MetadataValue, MetadataValueFilter, MetadatumViewModel } from './metadata.models'; +import { Metadata } from './metadata.utils'; +import { isUndefined } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { Observable } from 'rxjs'; -import { autoserialize } from 'cerialize'; +import { hasNoValue } from '../../shared/empty.util'; /** * An abstract model class for a DSpaceObject. */ export class DSpaceObject implements CacheableObject, ListableObject { + private _name: string; + self: string; /** * The human-readable identifier of this DSpaceObject */ - @autoserialize id: string; /** * The universally unique identifier of this DSpaceObject */ - @autoserialize uuid: string; /** @@ -34,14 +36,28 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * The name for this DSpaceObject */ - @autoserialize - name: string; + get name(): string { + return (isUndefined(this._name)) ? this.firstMetadataValue('dc.title') : this._name; + } /** - * An array containing all metadata of this DSpaceObject + * The name for this DSpaceObject */ - @autoserialize - metadata: Metadatum[]; + set name(name) { + this._name = name; + } + + /** + * All metadata of this DSpaceObject + */ + metadata: MetadataMap; + + /** + * Retrieve the current metadata as a list of MetadatumViewModels + */ + get metadataAsList(): MetadatumViewModel[] { + return Metadata.toViewModelList(this.metadata); + } /** * An array of DSpaceObjects that are direct parents of this DSpaceObject @@ -54,40 +70,76 @@ export class DSpaceObject implements CacheableObject, ListableObject { owner: Observable>; /** - * Find a metadata field by key and language + * Gets all matching metadata in this DSpaceObject. * - * This method returns the value of the first element - * in the metadata array that matches the provided - * key and language - * - * @param key - * @param language - * @return string + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue[]} the matching values or an empty array. */ - findMetadata(key: string, language?: string): string { - const metadatum = this.metadata.find((m: Metadatum) => { - return m.key === key && (isEmpty(language) || m.language === language) - }); - if (isNotEmpty(metadatum)) { - return metadatum.value; - } else { - return undefined; - } + allMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue[] { + return Metadata.all(this.metadata, keyOrKeys, valueFilter); } /** - * Find metadata by an array of keys + * Like [[allMetadata]], but only returns string values. * - * This method returns the values of the element - * in the metadata array that match the provided - * key(s) - * - * @param key(s) - * @return Array + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string[]} the matching string values or an empty array. */ - filterMetadata(keys: string[]): Metadatum[] { - return this.metadata.filter((metadatum: Metadatum) => { - return keys.some((key) => key === metadatum.key); + allMetadataValues(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string[] { + return Metadata.allValues(this.metadata, keyOrKeys, valueFilter); + } + + /** + * Gets the first matching MetadataValue object in this DSpaceObject, or `undefined`. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue} the first matching value, or `undefined`. + */ + firstMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue { + return Metadata.first(this.metadata, keyOrKeys, valueFilter); + } + + /** + * Like [[firstMetadata]], but only returns a string value, or `undefined`. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string} the first matching string value, or `undefined`. + */ + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { + return Metadata.firstValue(this.metadata, keyOrKeys, valueFilter); + } + + /** + * Checks for a matching metadata value in this DSpaceObject. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {boolean} whether a match is found. + */ + hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean { + return Metadata.has(this.metadata, keyOrKeys, valueFilter); + } + + /** + * Find metadata on a specific field and order all of them using their "place" property. + * @param key + */ + findMetadataSortedByPlace(key: string): MetadataValue[] { + return this.allMetadata([key]).sort((a: MetadataValue, b: MetadataValue) => { + if (hasNoValue(a.place) && hasNoValue(b.place)) { + return 0; + } + if (hasNoValue(a.place)) { + return -1; + } + if (hasNoValue(b.place)) { + return 1; + } + return a.place - b.place; }); } diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts new file mode 100644 index 0000000000..7e89a4e5dd --- /dev/null +++ b/src/app/core/shared/file.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; + +import { DSpaceRESTv2Service, HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { saveAs } from 'file-saver'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; + +/** + * Provides utility methods to save files on the client-side. + */ +@Injectable() +export class FileService { + constructor( + private restService: DSpaceRESTv2Service + ) { } + + /** + * Makes a HTTP Get request to download a file + * + * @param url + * file url + */ + downloadFile(url: string) { + const headers = new HttpHeaders(); + const options: HttpOptions = Object.create({headers, responseType: 'blob'}); + return this.restService.request(RestRequestMethod.GET, url, null, options) + .subscribe((data) => { + saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data)); + }); + } + + /** + * Derives file name from the http response + * by looking inside content-disposition + * @param res + * http DSpaceRESTV2Response + */ + getFileNameFromResponseContentDisposition(res: DSpaceRESTV2Response) { + // NOTE: to be able to retrieve 'Content-Disposition' header, + // you need to set 'Access-Control-Expose-Headers': 'Content-Disposition' ON SERVER SIDE + const contentDisposition = res.headers.get('content-disposition') || ''; + const matches = /filename="([^;]+)"/ig.exec(contentDisposition) || []; + return (matches[1] || 'untitled').trim().replace(/\.[^/.]+$/, ''); + }; +} diff --git a/src/app/core/shared/item-relationships/item-type.model.ts b/src/app/core/shared/item-relationships/item-type.model.ts new file mode 100644 index 0000000000..e4f98ab653 --- /dev/null +++ b/src/app/core/shared/item-relationships/item-type.model.ts @@ -0,0 +1,27 @@ +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../resource-type'; + +/** + * Describes a type of Item + */ +export class ItemType implements CacheableObject { + /** + * The identifier of this ItemType + */ + id: string; + + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The universally unique identifier of this ItemType + */ + uuid: string; +} diff --git a/src/app/core/shared/item-relationships/relationship-type.model.ts b/src/app/core/shared/item-relationships/relationship-type.model.ts new file mode 100644 index 0000000000..404d8cdb4b --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship-type.model.ts @@ -0,0 +1,75 @@ +import { Observable } from 'rxjs'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { RemoteData } from '../../data/remote-data'; +import { ResourceType } from '../resource-type'; +import { ItemType } from './item-type.model'; + +/** + * Describes a type of Relationship between multiple possible Items + */ +export class RelationshipType implements CacheableObject { + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The label that describes this RelationshipType + */ + label: string; + + /** + * The identifier of this RelationshipType + */ + id: string; + + /** + * The universally unique identifier of this RelationshipType + */ + uuid: string; + + /** + * The label that describes the Relation to the left of this RelationshipType + */ + leftLabel: string; + + /** + * The maximum amount of Relationships allowed to the left of this RelationshipType + */ + leftMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the left of this RelationshipType + */ + leftMinCardinality: number; + + /** + * The label that describes the Relation to the right of this RelationshipType + */ + rightLabel: string; + + /** + * The maximum amount of Relationships allowed to the right of this RelationshipType + */ + rightMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the right of this RelationshipType + */ + rightMinCardinality: number; + + /** + * The type of Item found to the left of this RelationshipType + */ + leftType: Observable>; + + /** + * The type of Item found to the right of this RelationshipType + */ + rightType: Observable>; +} diff --git a/src/app/core/shared/item-relationships/relationship.model.ts b/src/app/core/shared/item-relationships/relationship.model.ts new file mode 100644 index 0000000000..df8f04cd8a --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship.model.ts @@ -0,0 +1,55 @@ +import { Observable } from 'rxjs'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { RemoteData } from '../../data/remote-data'; +import { ResourceType } from '../resource-type'; +import { RelationshipType } from './relationship-type.model'; + +/** + * Describes a Relationship between two Items + */ +export class Relationship implements CacheableObject { + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The universally unique identifier of this Relationship + */ + uuid: string; + + /** + * The identifier of this Relationship + */ + id: string; + + /** + * The identifier of the Item to the left side of this Relationship + */ + leftId: string; + + /** + * The identifier of the Item to the right side of this Relationship + */ + rightId: string; + + /** + * The place of the Item to the left side of this Relationship + */ + leftPlace: number; + + /** + * The place of the Item to the right side of this Relationship + */ + rightPlace: number; + + /** + * The type of Relationship + */ + relationshipType: Observable>; +} diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 69def7b969..839103b9f5 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,12 +1,13 @@ -import {map, startWith, filter} from 'rxjs/operators'; +import { map, startWith, filter, take } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; import { RemoteData } from '../data/remote-data'; import { Bitstream } from './bitstream.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { PaginatedList } from '../data/paginated-list'; +import { Relationship } from './item-relationships/relationship.model'; export class Item extends DSpaceObject { @@ -51,6 +52,8 @@ export class Item extends DSpaceObject { bitstreams: Observable>>; + relationships: Observable>>; + /** * Retrieves the thumbnail of this item * @returns {Observable} the primaryBitstream of the 'THUMBNAIL' bundle @@ -87,17 +90,21 @@ export class Item extends DSpaceObject { * Retrieves bitstreams by bundle name * @param bundleName The name of the Bundle that should be returned * @returns {Observable} the bitstreams with the given bundleName + * TODO now that bitstreams can be paginated this should move to the server + * see https://github.com/DSpace/dspace-angular/issues/332 */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams.pipe( + filter((rd: RemoteData>) => !rd.isResponsePending && isNotUndefined(rd.payload)), map((rd: RemoteData>) => rd.payload.page), filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), + take(1), startWith([]), map((bitstreams) => { return bitstreams .filter((bitstream) => hasValue(bitstream)) .filter((bitstream) => bitstream.bundleName === bundleName) - }),); + })); } } diff --git a/src/app/core/shared/license.model.ts b/src/app/core/shared/license.model.ts new file mode 100644 index 0000000000..a04422242a --- /dev/null +++ b/src/app/core/shared/license.model.ts @@ -0,0 +1,14 @@ +import { DSpaceObject } from './dspace-object.model'; + +export class License extends DSpaceObject { + + /** + * Is the license custom? + */ + custom: boolean; + + /** + * The text of the license + */ + text: string; +} diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts new file mode 100644 index 0000000000..f31f8617ad --- /dev/null +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts @@ -0,0 +1,43 @@ +import { MetadataRepresentationType } from '../metadata-representation.model'; +import { ItemMetadataRepresentation, ItemTypeToValue } from './item-metadata-representation.model'; +import { Item } from '../../item.model'; +import { MetadataMap, MetadataValue } from '../../metadata.models'; + +describe('ItemMetadataRepresentation', () => { + const valuePrefix = 'Test value for '; + const item = new Item(); + let itemMetadataRepresentation: ItemMetadataRepresentation; + const metadataMap = new MetadataMap(); + for (const key of Object.keys(ItemTypeToValue)) { + metadataMap[ItemTypeToValue[key]] = [Object.assign(new MetadataValue(), { + value: `${valuePrefix}${ItemTypeToValue[key]}` + })]; + } + item.metadata = metadataMap; + + for (const itemType of Object.keys(ItemTypeToValue)) { + describe(`when creating an ItemMetadataRepresentation`, () => { + beforeEach(() => { + item.metadata['relationship.type'] = [ + Object.assign(new MetadataValue(), { + value: itemType + }) + ]; + + itemMetadataRepresentation = Object.assign(new ItemMetadataRepresentation(), item); + }); + + it('should have a representation type of item', () => { + expect(itemMetadataRepresentation.representationType).toEqual(MetadataRepresentationType.Item); + }); + + it('should return the correct value when calling getValue', () => { + expect(itemMetadataRepresentation.getValue()).toEqual(`${valuePrefix}${ItemTypeToValue[itemType]}`); + }); + + it('should return the correct item type', () => { + expect(itemMetadataRepresentation.itemType).toEqual(item.firstMetadataValue('relationship.type')); + }); + }); + } +}); diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts new file mode 100644 index 0000000000..7ec1445613 --- /dev/null +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts @@ -0,0 +1,46 @@ +import { Item } from '../../item.model'; +import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; +import { hasValue } from '../../../../shared/empty.util'; + +/** + * An object to convert item types into the metadata field it should render for the item's value + */ +export const ItemTypeToValue = { + Default: 'dc.title', + Person: 'dc.contributor.author', + OrgUnit: 'dc.title' +}; + +/** + * This class determines which fields to use when rendering an Item as a metadata value. + */ +export class ItemMetadataRepresentation extends Item implements MetadataRepresentation { + + /** + * The type of item this item can be represented as + */ + get itemType(): string { + return this.firstMetadataValue('relationship.type'); + } + + /** + * Fetch the way this item should be rendered as in a list + */ + get representationType(): MetadataRepresentationType { + return MetadataRepresentationType.Item; + } + + /** + * Get the value to display, depending on the itemType + */ + getValue(): string { + let metadata; + if (hasValue(ItemTypeToValue[this.itemType])) { + metadata = ItemTypeToValue[this.itemType]; + } else { + metadata = ItemTypeToValue.Default; + } + return this.firstMetadataValue(metadata); + } + +} diff --git a/src/app/core/shared/metadata-representation/metadata-representation.model.ts b/src/app/core/shared/metadata-representation/metadata-representation.model.ts new file mode 100644 index 0000000000..58e5bf906f --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadata-representation.model.ts @@ -0,0 +1,31 @@ +/** + * An Enum defining the representation type of metadata + */ +export enum MetadataRepresentationType { + None = 'none', + Item = 'item', + AuthorityControlled = 'authority_controlled', + PlainText = 'plain_text' +} + +/** + * An interface containing information about how we should represent certain metadata + */ +export interface MetadataRepresentation { + /** + * The type of item this metadata is representing + * e.g. 'Person' + * This can be used for template matching + */ + itemType: string; + + /** + * How we should render the metadata in a list + */ + representationType: MetadataRepresentationType, + + /** + * Fetches the value to be displayed + */ + getValue(): string +} diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts new file mode 100644 index 0000000000..ea48d345c5 --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts @@ -0,0 +1,54 @@ +import { MetadatumRepresentation } from './metadatum-representation.model'; +import { MetadataRepresentationType } from '../metadata-representation.model'; +import { MetadataValue } from '../../metadata.models'; + +describe('MetadatumRepresentation', () => { + const itemType = 'Person'; + const normalMetadatum = Object.assign(new MetadataValue(), { + key: 'dc.contributor.author', + value: 'Test Author' + }); + const authorityMetadatum = Object.assign(new MetadataValue(), { + key: 'dc.contributor.author', + value: 'Test Authority Author', + authority: '1234' + }); + + let metadatumRepresentation: MetadatumRepresentation; + + describe('when creating a MetadatumRepresentation based on a standard Metadatum object', () => { + beforeEach(() => { + metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), normalMetadatum); + }); + + it('should have a representation type of plain text', () => { + expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.PlainText); + }); + + it('should return the correct value when calling getPrimaryValue', () => { + expect(metadatumRepresentation.getValue()).toEqual(normalMetadatum.value); + }); + + it('should return the correct item type', () => { + expect(metadatumRepresentation.itemType).toEqual(itemType); + }); + }); + + describe('when creating a MetadatumRepresentation based on an authority controlled Metadatum object', () => { + beforeEach(() => { + metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), authorityMetadatum); + }); + + it('should have a representation type of plain text', () => { + expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.AuthorityControlled); + }); + + it('should return the correct value when calling getValue', () => { + expect(metadatumRepresentation.getValue()).toEqual(authorityMetadatum.value); + }); + + it('should return the correct item type', () => { + expect(metadatumRepresentation.itemType).toEqual(itemType); + }); + }); +}); diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts new file mode 100644 index 0000000000..595147f3e6 --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts @@ -0,0 +1,38 @@ +import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; +import { hasValue } from '../../../../shared/empty.util'; +import { MetadataValue } from '../../metadata.models'; + +/** + * This class defines the way the metadatum it extends should be represented + */ +export class MetadatumRepresentation extends MetadataValue implements MetadataRepresentation { + + /** + * The type of item this metadatum can be represented as + */ + itemType: string; + + constructor(itemType: string) { + super(); + this.itemType = itemType; + } + + /** + * Fetch the way this metadatum should be rendered as in a list + */ + get representationType(): MetadataRepresentationType { + if (hasValue(this.authority)) { + return MetadataRepresentationType.AuthorityControlled; + } else { + return MetadataRepresentationType.PlainText; + } + } + + /** + * Get the value to display + */ + getValue(): string { + return this.value; + } + +} diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts new file mode 100644 index 0000000000..9c7e30dcb4 --- /dev/null +++ b/src/app/core/shared/metadata.models.ts @@ -0,0 +1,138 @@ +import * as uuidv4 from 'uuid/v4'; +import { autoserialize, Serialize, Deserialize } from 'cerialize'; +import { hasValue } from '../../shared/empty.util'; +/* tslint:disable:max-classes-per-file */ + +const VIRTUAL_METADATA_PREFIX = 'virtual::'; + +/** A single metadata value and its properties. */ +export interface MetadataValueInterface { + + /** The language. */ + language: string; + + /** The string value. */ + value: string; +} + +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +export interface MetadataMapInterface { + [key: string]: MetadataValueInterface[]; +} + +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +export class MetadataMap implements MetadataMapInterface { + [key: string]: MetadataValue[]; +} + +/** A single metadata value and its properties. */ +export class MetadataValue implements MetadataValueInterface { + /** The uuid. */ + uuid: string = uuidv4(); + + /** The language. */ + @autoserialize + language: string; + + /** The string value. */ + @autoserialize + value: string; + + /** + * The place of this MetadataValue within his list of metadata + * This is used to render metadata in a specific custom order + */ + @autoserialize + place: number; + + /** The authority key used for authority-controlled metadata */ + @autoserialize + authority: string; + + /** The authority confidence value */ + @autoserialize + confidence: number; + + /** + * Returns true if this Metadatum's authority key starts with 'virtual::' + */ + get isVirtual(): boolean { + return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX); + } + + /** + * If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'. + * Returns undefined otherwise. + */ + get virtualValue(): string { + if (this.isVirtual) { + return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); + } else { + return undefined; + } + } +} + +/** Constraints for matching metadata values. */ +export interface MetadataValueFilter { + /** The language constraint. */ + language?: string; + + /** The value constraint. */ + value?: string; + + /** Whether the value constraint should match without regard to case. */ + ignoreCase?: boolean; + + /** Whether the value constraint should match as a substring. */ + substring?: boolean; +} + +export class MetadatumViewModel { + /** The uuid. */ + uuid: string = uuidv4(); + + /** The metadatafield key. */ + key: string; + + /** The language. */ + language: string; + + /** The string value. */ + value: string; + + /** + * The place of this MetadataValue within his list of metadata + * This is used to render metadata in a specific custom order + */ + place: number; + + /** The authority key used for authority-controlled metadata */ + authority: string; + + /** The authority confidence value */ + confidence: number; +} + +/** Serializer used for MetadataMaps. + * This is necessary because Cerialize has trouble instantiating the MetadataValues using their constructor + * when they are inside arrays which also represent the values in a map. + */ +export const MetadataMapSerializer = { + Serialize(map: MetadataMap): any { + const json = {}; + Object.keys(map).forEach((key: string) => { + json[key] = Serialize(map[key], MetadataValue); + }); + return json; + }, + + Deserialize(json: any): MetadataMap { + const metadataMap: MetadataMap = {}; + Object.keys(json).forEach((key: string) => { + metadataMap[key] = Deserialize(json[key], MetadataValue); + }); + return metadataMap; + } +}; +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts new file mode 100644 index 0000000000..1e1d7f86d5 --- /dev/null +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -0,0 +1,217 @@ +import { isUndefined } from '../../shared/empty.util'; +import * as uuidv4 from 'uuid/v4'; +import { + MetadataMap, + MetadataValue, + MetadataValueFilter, + MetadatumViewModel +} from './metadata.models'; +import { Metadata } from './metadata.utils'; + +const mdValue = (value: string, language?: string): MetadataValue => { + return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: undefined, confidence: undefined }); +}; + +const dcDescription = mdValue('Some description'); +const dcAbstract = mdValue('Some abstract'); +const dcTitle0 = mdValue('Title 0'); +const dcTitle1 = mdValue('Title 1'); +const dcTitle2 = mdValue('Title 2', 'en_US'); +const bar = mdValue('Bar'); + +const singleMap = { 'dc.title': [dcTitle0] }; + +const multiMap = { + 'dc.description': [dcDescription], + 'dc.description.abstract': [dcAbstract], + 'dc.title': [dcTitle1, dcTitle2], + 'foo': [bar] +}; + +const multiViewModelList = [ + { key: 'dc.description', ...dcDescription, order: 0 }, + { key: 'dc.description.abstract', ...dcAbstract, order: 0 }, + { key: 'dc.title', ...dcTitle1, order: 0 }, + { key: 'dc.title', ...dcTitle2, order: 1 }, + { key: 'foo', ...bar, order: 0 } +]; + +const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => { + const keys = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; + describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys))) + + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { + const result = fn(mapOrMaps, keys, filter); + let shouldReturn; + if (resultKind === 'boolean') { + shouldReturn = expected; + } else if (isUndefined(expected)) { + shouldReturn = 'undefined'; + } else if (expected instanceof Array) { + shouldReturn = 'an array with ' + expected.length + ' ' + (expected.length > 1 ? 'ordered ' : '') + + resultKind + (expected.length !== 1 ? 's' : ''); + } else { + shouldReturn = 'a ' + resultKind; + } + it('should return ' + shouldReturn, () => { + expect(result).toEqual(expected); + }); + }) +}; + +describe('Metadata', () => { + + describe('all method', () => { + + const testAll = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, expected, filter); + + describe('with emptyMap', () => { + testAll({}, 'foo', []); + testAll({}, '*', []); + }); + describe('with singleMap', () => { + testAll(singleMap, 'foo', []); + testAll(singleMap, '*', [dcTitle0]); + testAll(singleMap, '*', [], { value: 'baz' }); + testAll(singleMap, 'dc.title', [dcTitle0]); + testAll(singleMap, 'dc.*', [dcTitle0]); + }); + describe('with multiMap', () => { + testAll(multiMap, 'foo', [bar]); + testAll(multiMap, '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll(multiMap, 'dc.title', [dcTitle1, dcTitle2]); + testAll(multiMap, 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll(multiMap, ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); + }); + describe('with [ singleMap, multiMap ]', () => { + testAll([singleMap, multiMap], 'foo', [bar]); + testAll([singleMap, multiMap], '*', [dcTitle0]); + testAll([singleMap, multiMap], 'dc.title', [dcTitle0]); + testAll([singleMap, multiMap], 'dc.*', [dcTitle0]); + }); + describe('with [ multiMap, singleMap ]', () => { + testAll([multiMap, singleMap], 'foo', [bar]); + testAll([multiMap, singleMap], '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll([multiMap, singleMap], 'dc.title', [dcTitle1, dcTitle2]); + testAll([multiMap, singleMap], 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll([multiMap, singleMap], ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); + }); + }); + + describe('allValues method', () => { + + const testAllValues = (mapOrMaps, keyOrKeys, expected) => + testMethod(Metadata.allValues, 'string', mapOrMaps, keyOrKeys, expected); + + describe('with emptyMap', () => { + testAllValues({}, '*', []); + }); + describe('with singleMap', () => { + testAllValues([singleMap, multiMap], '*', [dcTitle0.value]); + }); + describe('with [ multiMap, singleMap ]', () => { + testAllValues([multiMap, singleMap], '*', [dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value]); + }); + }); + + describe('first method', () => { + + const testFirst = (mapOrMaps, keyOrKeys, expected) => + testMethod(Metadata.first, 'value', mapOrMaps, keyOrKeys, expected); + + describe('with emptyMap', () => { + testFirst({}, '*', undefined); + }); + describe('with singleMap', () => { + testFirst(singleMap, '*', dcTitle0); + }); + describe('with [ multiMap, singleMap ]', () => { + testFirst([multiMap, singleMap], '*', dcDescription); + }); + }); + + describe('firstValue method', () => { + + const testFirstValue = (mapOrMaps, keyOrKeys, expected) => + testMethod(Metadata.firstValue, 'value', mapOrMaps, keyOrKeys, expected); + + describe('with emptyMap', () => { + testFirstValue({}, '*', undefined); + }); + describe('with singleMap', () => { + testFirstValue(singleMap, '*', dcTitle0.value); + }); + describe('with [ multiMap, singleMap ]', () => { + testFirstValue([multiMap, singleMap], '*', dcDescription.value); + }); + }); + + describe('has method', () => { + + const testHas = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.has, 'boolean', mapOrMaps, keyOrKeys, expected, filter); + + describe('with emptyMap', () => { + testHas({}, '*', false); + }); + describe('with singleMap', () => { + testHas(singleMap, '*', true); + testHas(singleMap, '*', false, { value: 'baz' }); + }); + describe('with [ multiMap, singleMap ]', () => { + testHas([multiMap, singleMap], '*', true); + }); + }); + + describe('valueMatches method', () => { + + const testValueMatches = (value: MetadataValue, expected: boolean, filter?: MetadataValueFilter) => { + describe('with value ' + JSON.stringify(value) + ' and filter ' + + (isUndefined(filter) ? 'undefined' : JSON.stringify(filter)), () => { + const result = Metadata.valueMatches(value, filter); + it('should return ' + expected, () => { + expect(result).toEqual(expected); + }); + }); + }; + + testValueMatches(mdValue('a'), true); + testValueMatches(mdValue('a'), true, { value: 'a' }); + testValueMatches(mdValue('a'), false, { value: 'A' }); + testValueMatches(mdValue('a'), true, { value: 'A', ignoreCase: true }); + testValueMatches(mdValue('ab'), false, { value: 'b' }); + testValueMatches(mdValue('ab'), true, { value: 'b', substring: true }); + testValueMatches(mdValue('a'), true, { language: null }); + testValueMatches(mdValue('a'), false, { language: 'en_US' }); + testValueMatches(mdValue('a', 'en_US'), true, { language: 'en_US' }); + }); + + describe('toViewModelList method', () => { + + const testToViewModelList = (map: MetadataMap, expected: MetadatumViewModel[]) => { + describe('with map ' + JSON.stringify(map), () => { + const result = Metadata.toViewModelList(map); + it('should return ' + JSON.stringify(expected), () => { + expect(result).toEqual(expected); + }); + }); + }; + + testToViewModelList(multiMap, multiViewModelList); + }); + + describe('toMetadataMap method', () => { + + const testToMetadataMap = (metadatumList: MetadatumViewModel[], expected: MetadataMap) => { + describe('with metadatum list ' + JSON.stringify(metadatumList), () => { + const result = Metadata.toMetadataMap(metadatumList); + it('should return ' + JSON.stringify(expected), () => { + expect(result).toEqual(expected); + }); + }); + }; + + testToMetadataMap(multiViewModelList, multiMap); + }); + +}); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts new file mode 100644 index 0000000000..62a1957e22 --- /dev/null +++ b/src/app/core/shared/metadata.utils.ts @@ -0,0 +1,218 @@ +import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import { + MetadataMapInterface, + MetadataValue, + MetadataValueFilter, + MetadatumViewModel +} from './metadata.models'; +import { groupBy, sortBy } from 'lodash'; + +/** + * Utility class for working with DSpace object metadata. + * + * When specifying metadata keys, wildcards are supported, so `'*'` will match all keys, `'dc.date.*'` will + * match all qualified dc dates, and so on. Exact keys will be evaluated (and matches returned) in the order + * they are given. + * + * When multiple keys in a map match a given wildcard, they are evaluated in the order they are stored in + * the map (alphanumeric if obtained from the REST api). If duplicate or overlapping keys are specified, the + * first one takes precedence. For example, specifying `['dc.date', 'dc.*', '*']` will cause any `dc.date` + * values to be evaluated (and returned, if matched) first, followed by any other `dc` metadata values, + * followed by any other (non-dc) metadata values. + */ +export class Metadata { + + /** + * Gets all matching metadata in the map(s). + * + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be + * checked in order, and only values from the first with at least one match will be returned. + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue[]} the matching values or an empty array. + */ + public static all(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): MetadataValue[] { + const mdMaps: MetadataMapInterface[] = mapOrMaps instanceof Array ? mapOrMaps : [mapOrMaps]; + const matches: MetadataValue[] = []; + for (const mdMap of mdMaps) { + for (const mdKey of Metadata.resolveKeys(mdMap, keyOrKeys)) { + const candidates = mdMap[mdKey]; + if (candidates) { + for (const candidate of candidates) { + if (Metadata.valueMatches(candidate as MetadataValue, filter)) { + matches.push(candidate as MetadataValue); + } + } + } + } + if (!isEmpty(matches)) { + return matches; + } + } + return matches; + } + + /** + * Like [[Metadata.all]], but only returns string values. + * + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be + * checked in order, and only values from the first with at least one match will be returned. + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string[]} the matching string values or an empty array. + */ + public static allValues(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): string[] { + return Metadata.all(mapOrMaps, keyOrKeys, filter).map((mdValue) => mdValue.value); + } + + /** + * Gets the first matching MetadataValue object in the map(s), or `undefined`. + * + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue} the first matching value, or `undefined`. + */ + public static first(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): MetadataValue { + const mdMaps: MetadataMapInterface[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [mdMapOrMaps]; + for (const mdMap of mdMaps) { + for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) { + const values: MetadataValue[] = mdMap[key] as MetadataValue[]; + if (values) { + return values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); + } + } + } + } + + /** + * Like [[Metadata.first]], but only returns a string value, or `undefined`. + * + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string} the first matching string value, or `undefined`. + */ + public static firstValue(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): string { + const value = Metadata.first(mdMapOrMaps, keyOrKeys, filter); + return isUndefined(value) ? undefined : value.value; + } + + /** + * Checks for a matching metadata value in the given map(s). + * + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {boolean} whether a match is found. + */ + public static has(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): boolean { + return isNotUndefined(Metadata.first(mdMapOrMaps, keyOrKeys, filter)); + } + + /** + * Checks if a value matches a filter. + * + * @param {MetadataValue} mdValue the value to check. + * @param {MetadataValueFilter} filter the filter to use. + * @returns {boolean} whether the filter matches, or true if no filter is given. + */ + public static valueMatches(mdValue: MetadataValue, filter: MetadataValueFilter) { + if (!filter) { + return true; + } else if (filter.language && filter.language !== mdValue.language) { + return false; + } else if (filter.value) { + let fValue = filter.value; + let mValue = mdValue.value; + if (filter.ignoreCase) { + fValue = filter.value.toLowerCase(); + mValue = mdValue.value.toLowerCase(); + } + if (filter.substring) { + return mValue.includes(fValue); + } else { + return mValue === fValue; + } + } + return true; + } + + /** + * Gets the list of keys in the map limited by, and in the order given by `keyOrKeys`. + * + * @param {MetadataMapInterface} mdMap The source map. + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + */ + private static resolveKeys(mdMap: MetadataMapInterface = {}, keyOrKeys: string | string[]): string[] { + const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; + const outputKeys: string[] = []; + for (const inputKey of inputKeys) { + if (inputKey.includes('*')) { + const inputKeyRegex = new RegExp('^' + inputKey.replace('.', '\.').replace('*', '.*') + '$'); + for (const mapKey of Object.keys(mdMap)) { + if (!outputKeys.includes(mapKey) && inputKeyRegex.test(mapKey)) { + outputKeys.push(mapKey); + } + } + } else if (mdMap.hasOwnProperty(inputKey) && !outputKeys.includes(inputKey)) { + outputKeys.push(inputKey); + } + } + return outputKeys; + } + + /** + * Creates an array of MetadatumViewModels from an existing MetadataMapInterface. + * + * @param {MetadataMapInterface} mdMap The source map. + * @returns {MetadatumViewModel[]} List of metadata view models based on the source map. + */ + public static toViewModelList(mdMap: MetadataMapInterface): MetadatumViewModel[] { + let metadatumList: MetadatumViewModel[] = []; + Object.keys(mdMap) + .sort() + .forEach((key: string) => { + const fields = mdMap[key].map( + (metadataValue: MetadataValue, index: number) => + Object.assign( + {}, + metadataValue, + { + order: index, + key + })); + metadatumList = [...metadatumList, ...fields]; + }); + return metadatumList; + } + + /** + * Creates an MetadataMapInterface from an existing array of MetadatumViewModels. + * + * @param {MetadatumViewModel[]} viewModelList The source list. + * @returns {MetadataMapInterface} Map with metadata values based on the source list. + */ + public static toMetadataMap(viewModelList: MetadatumViewModel[]): MetadataMapInterface { + const metadataMap: MetadataMapInterface = {}; + const groupedList = groupBy(viewModelList, (viewModel) => viewModel.key); + Object.keys(groupedList) + .sort() + .forEach((key: string) => { + const orderedValues = sortBy(groupedList[key], ['order']); + metadataMap[key] = orderedValues.map((value: MetadatumViewModel) => { + const val = Object.assign(new MetadataValue(), value); + delete (val as any).order; + delete (val as any).key; + return val; + } + ) + }); + return metadataMap; + } +} diff --git a/src/app/core/shared/metadatum.model.ts b/src/app/core/shared/metadatum.model.ts deleted file mode 100644 index a3c5830608..0000000000 --- a/src/app/core/shared/metadatum.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { autoserialize } from 'cerialize'; - -export class Metadatum { - - /** - * The metadata field of this Metadatum - */ - @autoserialize - key: string; - - /** - * The language of this Metadatum - */ - @autoserialize - language: string; - - /** - * The value of this Metadatum - */ - @autoserialize - value: string; - -} diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 3c5fd1bdd9..81e93f729d 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -6,11 +6,18 @@ import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { configureRequest, - filterSuccessfulResponses, getAllSucceededRemoteData, - getRemoteDataPayload, getRequestFromRequestHref, getRequestFromRequestUUID, - getResourceLinksFromResponse, getResponseFromEntry, getSucceededRemoteData, + filterSuccessfulResponses, + getAllSucceededRemoteData, + getRemoteDataPayload, + getRequestFromRequestHref, + getRequestFromRequestUUID, + getResourceLinksFromResponse, + getResponseFromEntry, + getSucceededRemoteData, redirectToPageNotFoundOn404 } from './operators'; import { RemoteData } from '../data/remote-data'; +import { RemoteDataError } from '../data/remote-data-error'; +import { of as observableOf } from 'rxjs'; describe('Core Module - RxJS Operators', () => { let scheduler: TestScheduler; @@ -137,7 +144,7 @@ describe('Core Module - RxJS Operators', () => { scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(testRequest); + expect(requestService.configure).toHaveBeenCalledWith(testRequest, undefined); }); }); @@ -186,8 +193,52 @@ describe('Core Module - RxJS Operators', () => { .toEqual(new RemoteData(false, false, true, null, 'd'))); }); - }); + + describe('redirectToPageNotFoundOn404', () => { + let router; + beforeEach(() => { + router = jasmine.createSpyObj('router', ['navigateByUrl']); + }); + + it('should call navigateByUrl to a 404 page, when the remote data contains a 404 error', () => { + const testRD = new RemoteData(false, false, false, new RemoteDataError(404, 'Not Found', 'Object was not found'), undefined); + + observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/404', { skipLocationChange: true }); + }); + + it('should not call navigateByUrl to a 404 page, when the remote data contains another error than a 404', () => { + const testRD = new RemoteData(false, false, false, new RemoteDataError(500, 'Server Error', 'Something went wrong'), undefined); + + observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('should not call navigateByUrl to a 404 page, when the remote data contains no error', () => { + const testRD = new RemoteData(false, false, true, null, undefined); + + observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + + describe('getResponseFromEntry', () => { + it('should return the response for all not empty request entries, when they have a value', () => { + const source = hot('abcdefg', testRCEs); + const result = source.pipe(getResponseFromEntry()); + const expected = cold('abcde--', { + a: testRCEs.a.response, + b: testRCEs.b.response, + c: testRCEs.c.response, + d: testRCEs.d.response, + e: testRCEs.e.response + }); + + expect(result).toBeObservable(expected) + }); + }); + describe('getAllSucceededRemoteData', () => { it('should return all hasSucceeded RemoteData Observables', () => { const testRD = { diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index c5cf3373fa..ae46691e39 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,5 +1,5 @@ import { Observable } from 'rxjs'; -import { filter, find, flatMap, map, tap } from 'rxjs/operators'; +import { filter, find, flatMap, map, take, tap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; import { RemoteData } from '../data/remote-data'; @@ -10,6 +10,8 @@ import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { PaginatedList } from '../data/paginated-list'; import { SearchResult } from '../../+search-page/search-result.model'; +import { Item } from './item.model'; +import { Router } from '@angular/router'; /** * This file contains custom RxJS operators that can be used in multiple places @@ -50,9 +52,9 @@ export const getResourceLinksFromResponse = () => map((response: DSOSuccessResponse) => response.resourceSelfLinks), ); -export const configureRequest = (requestService: RequestService) => +export const configureRequest = (requestService: RequestService, forceBypassCache?: boolean) => (source: Observable): Observable => - source.pipe(tap((request: RestRequest) => requestService.configure(request))); + source.pipe(tap((request: RestRequest) => requestService.configure(request, forceBypassCache))); export const getRemoteDataPayload = () => (source: Observable>): Observable => @@ -60,7 +62,25 @@ export const getRemoteDataPayload = () => export const getSucceededRemoteData = () => (source: Observable>): Observable> => - source.pipe(find((rd: RemoteData) => rd.hasSucceeded), hasValueOperator()); + source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); + +/** + * Operator that checks if a remote data object contains a page not found error + * When it does contain such an error, it will redirect the user to a page not found, without altering the current URL + * @param router The router used to navigate to a new page + */ +export const redirectToPageNotFoundOn404 = (router: Router) => + (source: Observable>): Observable> => + source.pipe( + tap((rd: RemoteData) => { + if (rd.hasFailed && rd.error.statusCode === 404) { + router.navigateByUrl('/404', { skipLocationChange: true }); + } + })); + +export const getFinishedRemoteData = () => + (source: Observable>): Observable> => + source.pipe(find((rd: RemoteData) => !rd.isLoading)); export const getAllSucceededRemoteData = () => (source: Observable>): Observable> => @@ -71,7 +91,7 @@ export const toDSpaceObjectListRD = () => source.pipe( filter((rd: RemoteData>>) => rd.hasSucceeded), map((rd: RemoteData>>) => { - const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.dspaceObject); + const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.indexableObject); const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList; return Object.assign(rd, { payload: payload }); }) @@ -87,7 +107,7 @@ export const getBrowseDefinitionLinks = (definitionID: string) => source.pipe( getRemoteDataPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions - .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) + .find((def: BrowseDefinition) => def.id === definitionID) ), map((def: BrowseDefinition) => { if (isNotEmpty(def)) { @@ -97,3 +117,12 @@ export const getBrowseDefinitionLinks = (definitionID: string) => } }) ); + +/** + * Get the first occurrence of an object within a paginated list + */ +export const getFirstOccurrence = () => + (source: Observable>>): Observable> => + source.pipe( + map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined })) + ); diff --git a/src/app/core/shared/page-info.model.ts b/src/app/core/shared/page-info.model.ts index ba2af24dce..4ed281657d 100644 --- a/src/app/core/shared/page-info.model.ts +++ b/src/app/core/shared/page-info.model.ts @@ -39,4 +39,7 @@ export class PageInfo { @autoserialize first: string; + + @autoserialize + self: string; } diff --git a/src/app/core/shared/resource-policy.model.ts b/src/app/core/shared/resource-policy.model.ts index cccbea1e89..ee3d5293f5 100644 --- a/src/app/core/shared/resource-policy.model.ts +++ b/src/app/core/shared/resource-policy.model.ts @@ -18,9 +18,9 @@ export class ResourcePolicy implements CacheableObject { name: string; /** - * The Group this Resource Policy applies to + * The uuid of the Group this Resource Policy applies to */ - group: Group; + groupUUID: string; /** * The link to the rest endpoint where this Resource Policy can be found diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index e67f3339de..bdcc3a52f6 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -8,5 +8,21 @@ export enum ResourceType { Community = 'community', EPerson = 'eperson', Group = 'group', - ResourcePolicy = 'resourcePolicy' + ResourcePolicy = 'resourcePolicy', + MetadataSchema = 'metadataschema', + MetadataField = 'metadatafield', + Relationship = 'relationship', + RelationshipType = 'relationshiptype', + ItemType = 'entitytype', + License = 'license', + Workflowitem = 'workflowitem', + Workspaceitem = 'workspaceitem', + SubmissionDefinitions = 'submissiondefinitions', + SubmissionDefinition = 'submissiondefinition', + SubmissionForm = 'submissionform', + SubmissionForms = 'submissionforms', + SubmissionSections = 'submissionsections', + SubmissionSection = 'submissionsection', + ClaimedTask = 'claimedtask', + PoolTask = 'pooltask' } diff --git a/src/app/core/shared/selectors.ts b/src/app/core/shared/selectors.ts deleted file mode 100644 index 7bd35d39c1..0000000000 --- a/src/app/core/shared/selectors.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createSelector, MemoizedSelector } from '@ngrx/store'; -import { hasNoValue, isEmpty } from '../../shared/empty.util'; - -export function pathSelector(selector: MemoizedSelector, ...path: string[]): MemoizedSelector { - return createSelector(selector, (state: any) => getSubState(state, path)); -} - -function getSubState(state: any, path: string[]) { - const current = path[0]; - const remainingPath = path.slice(1); - const subState = state[current]; - if (hasNoValue(subState) || isEmpty(remainingPath)) { - return subState; - } else { - return getSubState(subState, remainingPath); - } -} diff --git a/src/app/core/shared/submit-data-response-definition.model.ts b/src/app/core/shared/submit-data-response-definition.model.ts new file mode 100644 index 0000000000..beb2b320cf --- /dev/null +++ b/src/app/core/shared/submit-data-response-definition.model.ts @@ -0,0 +1,8 @@ +import { ConfigObject } from '../config/models/config.model'; +import { SubmissionObject } from '../submission/models/submission-object.model'; + +/** + * Defines a type for submission request responses. + */ +export type SubmitDataResponseDefinitionObject + = Array; diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts index b026d68431..9c8d086097 100644 --- a/src/app/core/shared/view-mode.model.ts +++ b/src/app/core/shared/view-mode.model.ts @@ -4,5 +4,6 @@ export enum ViewMode { List = 'list', - Grid = 'grid' + Grid = 'grid', + Detail = 'detail' } diff --git a/src/app/core/submission/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts new file mode 100644 index 0000000000..f674ebdf72 --- /dev/null +++ b/src/app/core/submission/models/normalized-submission-object.model.ts @@ -0,0 +1,43 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; + +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; +import { SubmissionObjectError } from './submission-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * An abstract model class for a NormalizedSubmissionObject. + */ +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedSubmissionObject extends NormalizedDSpaceObject { + + /** + * The workspaceitem/workflowitem identifier + */ + @autoserialize + id: string; + + /** + * The workspaceitem/workflowitem identifier + */ + @autoserializeAs(String, 'id') + uuid: string; + + /** + * The workspaceitem/workflowitem last modified date + */ + @autoserialize + lastModified: Date; + + /** + * The workspaceitem/workflowitem last sections data + */ + @autoserialize + sections: WorkspaceitemSectionsObject; + + /** + * The workspaceitem/workflowitem last sections errors + */ + @autoserialize + errors: SubmissionObjectError[]; +} diff --git a/src/app/core/submission/models/normalized-workflowitem.model.ts b/src/app/core/submission/models/normalized-workflowitem.model.ts new file mode 100644 index 0000000000..a3fa8992a2 --- /dev/null +++ b/src/app/core/submission/models/normalized-workflowitem.model.ts @@ -0,0 +1,43 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; + +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { Workflowitem } from './workflowitem.model'; +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * An model class for a NormalizedWorkflowItem. + */ +@mapsTo(Workflowitem) +@inheritSerialization(NormalizedSubmissionObject) +export class NormalizedWorkflowItem extends NormalizedSubmissionObject { + + /** + * The collection this workflowitem belonging to + */ + @autoserialize + @relationship(ResourceType.Collection, false) + collection: string; + + /** + * The item created with this workflowitem + */ + @autoserialize + @relationship(ResourceType.Item, false) + item: string; + + /** + * The configuration object that define this workflowitem + */ + @autoserialize + @relationship(ResourceType.SubmissionDefinition, false) + submissionDefinition: string; + + /** + * The EPerson who submit this workflowitem + */ + @autoserialize + @relationship(ResourceType.EPerson, false) + submitter: string; + +} diff --git a/src/app/core/submission/models/normalized-workspaceitem.model.ts b/src/app/core/submission/models/normalized-workspaceitem.model.ts new file mode 100644 index 0000000000..7c15925c98 --- /dev/null +++ b/src/app/core/submission/models/normalized-workspaceitem.model.ts @@ -0,0 +1,45 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; + +import { Workspaceitem } from './workspaceitem.model'; +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { ResourceType } from '../../shared/resource-type'; +import { Workflowitem } from './workflowitem.model'; + +/** + * An model class for a NormalizedWorkspaceItem. + */ +@mapsTo(Workspaceitem) +@inheritSerialization(NormalizedDSpaceObject) +@inheritSerialization(NormalizedSubmissionObject) +export class NormalizedWorkspaceItem extends NormalizedSubmissionObject { + + /** + * The collection this workspaceitem belonging to + */ + @autoserialize + @relationship(ResourceType.Collection, false) + collection: string; + + /** + * The item created with this workspaceitem + */ + @autoserialize + @relationship(ResourceType.Item, false) + item: string; + + /** + * The configuration object that define this workspaceitem + */ + @autoserialize + @relationship(ResourceType.SubmissionDefinition, false) + submissionDefinition: string; + + /** + * The EPerson who submit this workspaceitem + */ + @autoserialize + @relationship(ResourceType.EPerson, false) + submitter: string; +} diff --git a/src/app/core/submission/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts new file mode 100644 index 0000000000..23f75553c5 --- /dev/null +++ b/src/app/core/submission/models/submission-object.model.ts @@ -0,0 +1,67 @@ +import { Observable } from 'rxjs'; + +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { RemoteData } from '../../data/remote-data'; +import { Collection } from '../../shared/collection.model'; +import { Item } from '../../shared/item.model'; +import { SubmissionDefinitionsModel } from '../../config/models/config-submission-definitions.model'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; + +export interface SubmissionObjectError { + message: string, + paths: string[], +} + +/** + * An abstract model class for a SubmissionObject. + */ +export abstract class SubmissionObject extends DSpaceObject implements CacheableObject, ListableObject { + + /** + * The workspaceitem/workflowitem identifier + */ + id: string; + + /** + * The workspaceitem/workflowitem identifier + */ + uuid: string; + + /** + * The workspaceitem/workflowitem last modified date + */ + lastModified: Date; + + /** + * The collection this submission applies to + */ + collection: Observable> | Collection; + + /** + * The submission item + */ + item: Observable> | Item; + + /** + * The workspaceitem/workflowitem last sections data + */ + sections: WorkspaceitemSectionsObject; + + /** + * The configuration object that define this submission + */ + submissionDefinition: Observable> | SubmissionDefinitionsModel; + + /** + * The workspaceitem submitter + */ + submitter: Observable> | EPerson; + + /** + * The workspaceitem/workflowitem last sections errors + */ + errors: SubmissionObjectError[]; +} diff --git a/src/app/core/submission/models/submission-upload-file-access-condition.model.ts b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts new file mode 100644 index 0000000000..8b89397f24 --- /dev/null +++ b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts @@ -0,0 +1,30 @@ +/** + * An interface to represent bitstream's access condition. + */ +export class SubmissionUploadFileAccessConditionObject { + + /** + * The access condition id + */ + id: string; + + /** + * The access condition name + */ + name: string; + + /** + * The access group UUID defined in this access condition + */ + groupUUID: string; + + /** + * Possible start date of the access condition + */ + startDate: string; + + /** + * Possible end date of the access condition + */ + endDate: string; +} diff --git a/src/app/core/submission/models/workflowitem.model.ts b/src/app/core/submission/models/workflowitem.model.ts new file mode 100644 index 0000000000..f1a0467f43 --- /dev/null +++ b/src/app/core/submission/models/workflowitem.model.ts @@ -0,0 +1,7 @@ +import { Workspaceitem } from './workspaceitem.model'; + +/** + * A model class for a Workflowitem. + */ +export class Workflowitem extends Workspaceitem { +} diff --git a/src/app/core/submission/models/workspaceitem-section-form.model.ts b/src/app/core/submission/models/workspaceitem-section-form.model.ts new file mode 100644 index 0000000000..1462a96d81 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-form.model.ts @@ -0,0 +1,10 @@ +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { MetadataMapInterface } from '../../shared/metadata.models'; + +/** + * An interface to represent submission's form section data. + * A map of metadata keys to an ordered list of FormFieldMetadataValueObject objects. + */ +export interface WorkspaceitemSectionFormObject extends MetadataMapInterface { + [metadata: string]: FormFieldMetadataValueObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-section-license.model.ts b/src/app/core/submission/models/workspaceitem-section-license.model.ts new file mode 100644 index 0000000000..26f625871e --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-license.model.ts @@ -0,0 +1,20 @@ + +/** + * An interface to represent submission's license section data. + */ +export interface WorkspaceitemSectionLicenseObject { + /** + * The license url + */ + url: string; + + /** + * The acceptance date of the license + */ + acceptanceDate: string; + + /** + * A boolean representing if license has been granted + */ + granted: boolean; +} diff --git a/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts new file mode 100644 index 0000000000..177473b7d5 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts @@ -0,0 +1,46 @@ +import { SubmissionUploadFileAccessConditionObject } from './submission-upload-file-access-condition.model'; +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; + +/** + * An interface to represent submission's upload section file entry. + */ +export class WorkspaceitemSectionUploadFileObject { + + /** + * The file UUID + */ + uuid: string; + + /** + * The file metadata + */ + metadata: WorkspaceitemSectionFormObject; + + /** + * The file size + */ + sizeBytes: number; + + /** + * The file check sum + */ + checkSum: { + checkSumAlgorithm: string; + value: string; + }; + + /** + * The file url + */ + url: string; + + /** + * The file thumbnail url + */ + thumbnail: string; + + /** + * The list of file access conditions + */ + accessConditions: SubmissionUploadFileAccessConditionObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-section-upload.model.ts b/src/app/core/submission/models/workspaceitem-section-upload.model.ts new file mode 100644 index 0000000000..f98e0584eb --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload.model.ts @@ -0,0 +1,12 @@ +import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-upload-file.model'; + +/** + * An interface to represent submission's upload section data. + */ +export interface WorkspaceitemSectionUploadObject { + + /** + * A list of [[WorkspaceitemSectionUploadFileObject]] + */ + files: WorkspaceitemSectionUploadFileObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts new file mode 100644 index 0000000000..165e69869c --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -0,0 +1,20 @@ +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; +import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; +import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; + +/** + * An interface to represent submission's section object. + * A map of section keys to an ordered list of WorkspaceitemSectionDataType objects. + */ +export class WorkspaceitemSectionsObject { + [name: string]: WorkspaceitemSectionDataType; +} + +/** + * Export a type alias of all sections + */ +export type WorkspaceitemSectionDataType + = WorkspaceitemSectionUploadObject + | WorkspaceitemSectionFormObject + | WorkspaceitemSectionLicenseObject + | string; diff --git a/src/app/core/submission/models/workspaceitem.model.ts b/src/app/core/submission/models/workspaceitem.model.ts new file mode 100644 index 0000000000..6548191ba2 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem.model.ts @@ -0,0 +1,8 @@ +import { SubmissionObject } from './submission-object.model'; + +/** + * A model class for a Workspaceitem. + */ +export class Workspaceitem extends SubmissionObject { + +} diff --git a/src/app/core/submission/submission-json-patch-operations.service.spec.ts b/src/app/core/submission/submission-json-patch-operations.service.spec.ts new file mode 100644 index 0000000000..39e6cd42fb --- /dev/null +++ b/src/app/core/submission/submission-json-patch-operations.service.spec.ts @@ -0,0 +1,37 @@ +import { Store } from '@ngrx/store'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { SubmissionJsonPatchOperationsService } from './submission-json-patch-operations.service'; +import { RequestService } from '../data/request.service'; +import { SubmissionPatchRequest } from '../data/request.models'; + +describe('SubmissionJsonPatchOperationsService', () => { + let scheduler: TestScheduler; + let service: SubmissionJsonPatchOperationsService; + const requestService = {} as RequestService; + const store = {} as Store; + const halEndpointService = {} as HALEndpointService; + + function initTestService() { + return new SubmissionJsonPatchOperationsService( + requestService, + store, + halEndpointService + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + }); + + it('should instantiate SubmissionJsonPatchOperationsService properly', () => { + expect(service).toBeDefined(); + expect((service as any).patchRequestConstructor).toEqual(SubmissionPatchRequest); + }); + +}); diff --git a/src/app/core/submission/submission-json-patch-operations.service.ts b/src/app/core/submission/submission-json-patch-operations.service.ts new file mode 100644 index 0000000000..d469f2098f --- /dev/null +++ b/src/app/core/submission/submission-json-patch-operations.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; + +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { JsonPatchOperationsService } from '../json-patch/json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../shared/submit-data-response-definition.model'; +import { SubmissionPatchRequest } from '../data/request.models'; +import { CoreState } from '../core.reducers'; + +/** + * A service that provides methods to make JSON Patch requests. + */ +@Injectable() +export class SubmissionJsonPatchOperationsService extends JsonPatchOperationsService { + protected linkPath = ''; + protected patchRequestConstructor = SubmissionPatchRequest; + + constructor( + protected requestService: RequestService, + protected store: Store, + protected halService: HALEndpointService) { + + super(); + } + +} diff --git a/src/app/core/submission/submission-resource-type.ts b/src/app/core/submission/submission-resource-type.ts new file mode 100644 index 0000000000..f5b8e2c423 --- /dev/null +++ b/src/app/core/submission/submission-resource-type.ts @@ -0,0 +1,21 @@ +export enum SubmissionResourceType { + Bundle = 'bundle', + Bitstream = 'bitstream', + BitstreamFormat = 'bitstreamformat', + Item = 'item', + Collection = 'collection', + Community = 'community', + ResourcePolicy = 'resourcePolicy', + License = 'license', + EPerson = 'eperson', + Group = 'group', + WorkspaceItem = 'workspaceitem', + WorkflowItem = 'workflowitem', + SubmissionDefinitions = 'submissiondefinitions', + SubmissionDefinition = 'submissiondefinition', + SubmissionForm = 'submissionform', + SubmissionForms = 'submissionforms', + SubmissionSections = 'submissionsections', + SubmissionSection = 'submissionsection', + Authority = 'authority' +} diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts new file mode 100644 index 0000000000..21135be463 --- /dev/null +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -0,0 +1,166 @@ +import { Inject, Injectable } from '@angular/core'; + +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../cache/response.models'; +import { isEmpty, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { ConfigObject } from '../config/models/config.model'; +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { SubmissionResourceType } from './submission-resource-type'; +import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; +import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; +import { FormFieldMetadataValueObject } from '../../shared/form/builder/models/form-field-metadata-value.model'; +import { SubmissionObject } from './models/submission-object.model'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; + +/** + * Export a function to check if object has same properties of FormFieldMetadataValueObject + * + * @param obj + */ +export function isServerFormValue(obj: any): boolean { + return (typeof obj === 'object' + && obj.hasOwnProperty('value') + && obj.hasOwnProperty('language') + && obj.hasOwnProperty('authority') + && obj.hasOwnProperty('confidence')) +} + +/** + * Export a function to normalize sections object of the server response + * + * @param obj + * @param objIndex + */ +export function normalizeSectionData(obj: any, objIndex?: number) { + let result: any = obj; + if (isNotNull(obj)) { + // If is an Instance of FormFieldMetadataValueObject normalize it + if (typeof obj === 'object' && isServerFormValue(obj)) { + // If authority property is set normalize as a FormFieldMetadataValueObject object + /* NOTE: Data received from server could have authority property equal to null, but into form + field's model is required a FormFieldMetadataValueObject object as field value, so instantiate it */ + result = new FormFieldMetadataValueObject( + obj.value, + obj.language, + obj.authority, + (obj.display || obj.value), + obj.place || objIndex, + obj.confidence, + obj.otherInformation + ); + } else if (Array.isArray(obj)) { + result = []; + obj.forEach((item, index) => { + result[index] = normalizeSectionData(item, index); + }); + } else if (typeof obj === 'object') { + result = Object.create({}); + Object.keys(obj) + .forEach((key) => { + result[key] = normalizeSectionData(obj[key]); + }); + } + } + return result; +} + +/** + * Provides methods to parse response for a submission request. + */ +@Injectable() +export class SubmissionResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = false; + + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService) { + super(); + } + + /** + * Parses data from the workspaceitems/workflowitems endpoints + * + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) + && isNotEmpty(data.payload._links) + && this.isSuccessStatus(data.statusCode)) { + const dataDefinition = this.processResponse(data.payload, request.href); + return new SubmissionSuccessResponse(dataDefinition, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else if (isEmpty(data.payload) && this.isSuccessStatus(data.statusCode)) { + return new SubmissionSuccessResponse(null, data.statusCode, data.statusText); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from server'), + {statusCode: data.statusCode, statusText: data.statusText} + ) + ); + } + } + + /** + * Parses response and normalize it + * + * @param {DSpaceRESTV2Response} data + * @param {string} requestHref + * @returns {any[]} + */ + protected processResponse(data: any, requestHref: string): any[] { + const dataDefinition = this.process(data, requestHref); + const normalizedDefinition = Array.of(); + const processedList = Array.isArray(dataDefinition) ? dataDefinition : Array.of(dataDefinition); + + processedList.forEach((item) => { + + let normalizedItem = Object.assign({}, item); + // In case data is an Instance of NormalizedWorkspaceItem normalize field value of all the section of type form + if (item instanceof NormalizedWorkspaceItem + || item instanceof NormalizedWorkflowItem) { + if (item.sections) { + const precessedSection = Object.create({}); + // Iterate over all workspaceitem's sections + Object.keys(item.sections) + .forEach((sectionId) => { + if (typeof item.sections[sectionId] === 'object' && isNotEmpty(item.sections[sectionId])) { + const normalizedSectionData = Object.create({}); + // Iterate over all sections property + Object.keys(item.sections[sectionId]) + .forEach((metdadataId) => { + const entry = item.sections[sectionId][metdadataId]; + // If entry is not an array, for sure is not a section of type form + if (Array.isArray(entry)) { + normalizedSectionData[metdadataId] = []; + entry.forEach((valueItem, index) => { + // Parse value and normalize it + const normValue = normalizeSectionData(valueItem, index); + if (isNotEmpty(normValue)) { + normalizedSectionData[metdadataId].push(normValue); + } + }); + } else { + normalizedSectionData[metdadataId] = entry; + } + }); + precessedSection[sectionId] = normalizedSectionData; + } + }); + normalizedItem = Object.assign({}, item, { sections: precessedSection }); + } + } + normalizedDefinition.push(normalizedItem); + }); + + return normalizedDefinition; + } + +} diff --git a/src/app/core/submission/submission-rest.service.spec.ts b/src/app/core/submission/submission-rest.service.spec.ts new file mode 100644 index 0000000000..6e748c5575 --- /dev/null +++ b/src/app/core/submission/submission-rest.service.spec.ts @@ -0,0 +1,88 @@ +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { SubmissionRestService } from './submission-rest.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { + SubmissionDeleteRequest, + SubmissionPatchRequest, + SubmissionPostRequest, + SubmissionRequest +} from '../data/request.models'; +import { FormFieldMetadataValueObject } from '../../shared/form/builder/models/form-field-metadata-value.model'; + +describe('SubmissionRestService test suite', () => { + let scheduler: TestScheduler; + let service: SubmissionRestService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let halService: any; + + const resourceEndpointURL = 'https://rest.api/endpoint'; + const resourceEndpoint = 'workspaceitems'; + const resourceScope = '260'; + const body = { test: new FormFieldMetadataValueObject('test')}; + const resourceHref = resourceEndpointURL + '/' + resourceEndpoint + '/' + resourceScope; + const timestampResponse = 1545994811992; + + function initTestService() { + return new SubmissionRestService( + rdbService, + requestService, + halService + ); + } + + beforeEach(() => { + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + }); + + describe('deleteById', () => { + it('should configure a new SubmissionDeleteRequest', () => { + const expected = new SubmissionDeleteRequest(requestService.generateRequestId(), resourceHref); + scheduler.schedule(() => service.deleteById(resourceScope, resourceEndpoint).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('getDataById', () => { + it('should configure a new SubmissionRequest', () => { + const expected = new SubmissionRequest(requestService.generateRequestId(), resourceHref); + scheduler.schedule(() => service.getDataById(resourceEndpoint, resourceScope).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('postToEndpoint', () => { + it('should configure a new SubmissionPostRequest', () => { + const expected = new SubmissionPostRequest(requestService.generateRequestId(), resourceHref, body); + scheduler.schedule(() => service.postToEndpoint(resourceEndpoint, body, resourceScope).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('patchToEndpoint', () => { + it('should configure a new SubmissionPatchRequest', () => { + const expected = new SubmissionPatchRequest(requestService.generateRequestId(), resourceHref, body); + scheduler.schedule(() => service.patchToEndpoint(resourceEndpoint, body, resourceScope).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); +}); diff --git a/src/app/core/submission/submission-rest.service.ts b/src/app/core/submission/submission-rest.service.ts new file mode 100644 index 0000000000..e2b8bb01c8 --- /dev/null +++ b/src/app/core/submission/submission-rest.service.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@angular/core'; + +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map, mergeMap, tap } from 'rxjs/operators'; + +import { RequestService } from '../data/request.service'; +import { isNotEmpty } from '../../shared/empty.util'; +import { + DeleteRequest, + PostRequest, + RestRequest, + SubmissionDeleteRequest, + SubmissionPatchRequest, + SubmissionPostRequest, + SubmissionRequest +} from '../data/request.models'; +import { SubmitDataResponseDefinitionObject } from '../shared/submit-data-response-definition.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../cache/response.models'; +import { getResponseFromEntry } from '../shared/operators'; + +/** + * The service handling all submission REST requests + */ +@Injectable() +export class SubmissionRestService { + protected linkPath = 'workspaceitems'; + + constructor( + protected rdbService: RemoteDataBuildService, + protected requestService: RequestService, + protected halService: HALEndpointService) { + } + + /** + * Fetch a RestRequest + * + * @param requestId + * The base endpoint for the type of object + * @return Observable + * server response + */ + protected fetchRequest(requestId: string): Observable { + const responses = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + const errorResponses = responses.pipe( + filter((response: RestResponse) => !response.isSuccessful), + mergeMap((error: ErrorResponse) => observableThrowError(error)) + ); + const successResponses = responses.pipe( + filter((response: RestResponse) => response.isSuccessful), + map((response: SubmissionSuccessResponse) => response.dataDefinition as any), + distinctUntilChanged() + ); + return observableMerge(errorResponses, successResponses); + } + + /** + * Create the HREF for a specific submission object based on its identifier + * + * @param endpoint + * The base endpoint for the type of object + * @param resourceID + * The identifier for the object + */ + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + /** + * Delete an existing submission Object on the server + * + * @param scopeId + * The submission Object to be removed + * @param linkName + * The endpoint link name + * @return Observable + * server response + */ + public deleteById(scopeId: string, linkName?: string): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName || this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + map((endpointURL: string) => new SubmissionDeleteRequest(requestId, endpointURL)), + tap((request: DeleteRequest) => this.requestService.configure(request)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Return an existing submission Object from the server + * + * @param linkName + * The endpoint link name + * @param id + * The submission Object to retrieve + * @return Observable + * server response + */ + public getDataById(linkName: string, id: string): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName).pipe( + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id)), + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new SubmissionRequest(requestId, endpointURL)), + tap((request: RestRequest) => this.requestService.configure(request, true)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Make a new post request + * + * @param linkName + * The endpoint link name + * @param body + * The post request body + * @param scopeId + * The submission Object id + * @param options + * The [HttpOptions] object + * @return Observable + * server response + */ + public postToEndpoint(linkName: string, body: any, scopeId?: string, options?: HttpOptions): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + distinctUntilChanged(), + map((endpointURL: string) => new SubmissionPostRequest(requestId, endpointURL, body, options)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Make a new patch to a specified object + * + * @param linkName + * The endpoint link name + * @param body + * The post request body + * @param scopeId + * The submission Object id + * @return Observable + * server response + */ + public patchToEndpoint(linkName: string, body: any, scopeId?: string): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + distinctUntilChanged(), + map((endpointURL: string) => new SubmissionPatchRequest(requestId, endpointURL, body)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + +} diff --git a/src/app/core/submission/submission-scope-type.ts b/src/app/core/submission/submission-scope-type.ts new file mode 100644 index 0000000000..6ed32d3b4e --- /dev/null +++ b/src/app/core/submission/submission-scope-type.ts @@ -0,0 +1,4 @@ +export enum SubmissionScopeType { + WorkspaceItem = 'WORKSPACE', + WorkflowItem = 'WORKFLOW' +} diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts new file mode 100644 index 0000000000..e739a62e81 --- /dev/null +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { Workflowitem } from './models/workflowitem.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from '../data/request.models'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; + +/** + * A service that provides methods to make REST requests with workflowitems endpoint. + */ +@Injectable() +export class WorkflowitemDataService extends DataService { + protected linkPath = 'workflowitems'; + protected forceBypassCache = true; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected store: Store) { + super(); + } + + public getBrowseEndpoint(options: FindAllOptions) { + return this.halService.getEndpoint(this.linkPath); + } + +} diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts new file mode 100644 index 0000000000..3bb3eb1ee8 --- /dev/null +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { Workspaceitem } from './models/workspaceitem.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from '../data/request.models'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; + +/** + * A service that provides methods to make REST requests with workspaceitems endpoint. + */ +@Injectable() +export class WorkspaceitemDataService extends DataService { + protected linkPath = 'workspaceitems'; + protected forceBypassCache = true; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected store: Store) { + super(); + } + + public getBrowseEndpoint(options: FindAllOptions) { + return this.halService.getEndpoint(this.linkPath); + } + +} diff --git a/src/app/core/tasks/claimed-task-data.service.spec.ts b/src/app/core/tasks/claimed-task-data.service.spec.ts new file mode 100644 index 0000000000..a7be0830ec --- /dev/null +++ b/src/app/core/tasks/claimed-task-data.service.spec.ts @@ -0,0 +1,108 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { CoreState } from '../core.reducers'; +import { ClaimedTaskDataService } from './claimed-task-data.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +describe('ClaimedTaskDataService', () => { + let service: ClaimedTaskDataService; + let options: HttpOptions; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'claimedtasks'; + const requestService: any = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store; + + function initTestService(): ClaimedTaskDataService { + return new ClaimedTaskDataService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + service = initTestService(); + options = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + }); + + describe('approveTask', () => { + + it('should call postToEndpoint method', () => { + const scopeId = '1234'; + const body = { + submit_approve: 'true' + }; + + spyOn(service, 'postToEndpoint'); + requestService.uriEncodeBody.and.returnValue(body); + + service.approveTask(scopeId); + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options); + }); + }); + + describe('rejectTask', () => { + + it('should call postToEndpoint method', () => { + const scopeId = '1234'; + const reason = 'test reject'; + const body = { + submit_reject: 'true', + reason + }; + + spyOn(service, 'postToEndpoint'); + requestService.uriEncodeBody.and.returnValue(body); + + service.rejectTask(reason, scopeId); + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options); + }); + }); + + describe('returnToPoolTask', () => { + + it('should call deleteById method', () => { + const scopeId = '1234'; + + spyOn(service, 'deleteById'); + + service.returnToPoolTask(scopeId); + + expect(service.deleteById).toHaveBeenCalledWith(linkPath, scopeId, options); + }); + }); +}); diff --git a/src/app/core/tasks/claimed-task-data.service.ts b/src/app/core/tasks/claimed-task-data.service.ts new file mode 100644 index 0000000000..dda42e1c67 --- /dev/null +++ b/src/app/core/tasks/claimed-task-data.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { RequestService } from '../data/request.service'; +import { ClaimedTask } from './models/claimed-task-object.model'; +import { TasksService } from './tasks.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ProcessTaskResponse } from './models/process-task-response'; + +/** + * The service handling all REST requests for ClaimedTask + */ +@Injectable() +export class ClaimedTaskDataService extends TasksService { + + /** + * The endpoint link name + */ + protected linkPath = 'claimedtasks'; + + /** + * When true, a new request is always dispatched + */ + protected forceBypassCache = true; + + /** + * Initialize instance variables + * + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {NormalizedObjectBuildService} dataBuildService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DSOChangeAnalyzer, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } + + /** + * Make a request to approve the given task + * + * @param scopeId + * The task id + * @return {Observable} + * Emit the server response + */ + public approveTask(scopeId: string): Observable { + const body = { + submit_approve: 'true' + }; + return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions()); + } + + /** + * Make a request to reject the given task + * + * @param reason + * The reason of reject + * @param scopeId + * The task id + * @return {Observable} + * Emit the server response + */ + public rejectTask(reason: string, scopeId: string): Observable { + const body = { + submit_reject: 'true', + reason + }; + return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions()); + } + + /** + * Make a request to return the given task to the pool + * + * @param scopeId + * The task id + * @return {Observable} + * Emit the server response + */ + public returnToPoolTask(scopeId: string): Observable { + return this.deleteById(this.linkPath, scopeId, this.makeHttpOptions()); + } + +} diff --git a/src/app/core/tasks/models/claimed-task-object.model.ts b/src/app/core/tasks/models/claimed-task-object.model.ts new file mode 100644 index 0000000000..212e75ed95 --- /dev/null +++ b/src/app/core/tasks/models/claimed-task-object.model.ts @@ -0,0 +1,8 @@ +import { TaskObject } from './task-object.model'; + +/** + * A model class for a ClaimedTask. + */ +export class ClaimedTask extends TaskObject { + +} diff --git a/src/app/core/tasks/models/normalized-claimed-task-object.model.ts b/src/app/core/tasks/models/normalized-claimed-task-object.model.ts new file mode 100644 index 0000000000..c2c3f12bc4 --- /dev/null +++ b/src/app/core/tasks/models/normalized-claimed-task-object.model.ts @@ -0,0 +1,39 @@ +import { NormalizedTaskObject } from './normalized-task-object.model'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { autoserialize, inheritSerialization } from 'cerialize'; +import { ClaimedTask } from './claimed-task-object.model'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * A normalized model class for a ClaimedTask. + */ +@mapsTo(ClaimedTask) +@inheritSerialization(NormalizedTaskObject) +export class NormalizedClaimedTask extends NormalizedTaskObject { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; + +} diff --git a/src/app/core/tasks/models/normalized-pool-task-object.model.ts b/src/app/core/tasks/models/normalized-pool-task-object.model.ts new file mode 100644 index 0000000000..22cda6ff9c --- /dev/null +++ b/src/app/core/tasks/models/normalized-pool-task-object.model.ts @@ -0,0 +1,38 @@ +import { NormalizedTaskObject } from './normalized-task-object.model'; +import { PoolTask } from './pool-task-object.model'; +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * A normalized model class for a PoolTask. + */ +@mapsTo(PoolTask) +@inheritSerialization(NormalizedTaskObject) +export class NormalizedPoolTask extends NormalizedTaskObject { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; +} diff --git a/src/app/core/tasks/models/normalized-task-object.model.ts b/src/app/core/tasks/models/normalized-task-object.model.ts new file mode 100644 index 0000000000..52c274e3a8 --- /dev/null +++ b/src/app/core/tasks/models/normalized-task-object.model.ts @@ -0,0 +1,39 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { TaskObject } from './task-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * An abstract normalized model class for a TaskObject. + */ +@mapsTo(TaskObject) +@inheritSerialization(NormalizedDSpaceObject) +export abstract class NormalizedTaskObject extends NormalizedDSpaceObject { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; +} diff --git a/src/app/core/tasks/models/pool-task-object.model.ts b/src/app/core/tasks/models/pool-task-object.model.ts new file mode 100644 index 0000000000..8d98d3e1a5 --- /dev/null +++ b/src/app/core/tasks/models/pool-task-object.model.ts @@ -0,0 +1,8 @@ +import { TaskObject } from './task-object.model'; + +/** + * A model class for a PoolTask. + */ +export class PoolTask extends TaskObject { + +} diff --git a/src/app/core/tasks/models/process-task-response.ts b/src/app/core/tasks/models/process-task-response.ts new file mode 100644 index 0000000000..ca4bc9a068 --- /dev/null +++ b/src/app/core/tasks/models/process-task-response.ts @@ -0,0 +1,17 @@ +import { RemoteDataError } from '../../data/remote-data-error'; + +/** + * A class to represent the data retrieved by after processing a task + */ +export class ProcessTaskResponse { + constructor( + private isSuccessful: boolean, + public error?: RemoteDataError, + public payload?: any + ) { + } + + get hasSucceeded(): boolean { + return this.isSuccessful; + } +} diff --git a/src/app/core/tasks/models/task-object.model.ts b/src/app/core/tasks/models/task-object.model.ts new file mode 100644 index 0000000000..97a1c9f59e --- /dev/null +++ b/src/app/core/tasks/models/task-object.model.ts @@ -0,0 +1,33 @@ +import { Observable } from 'rxjs'; + +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { RemoteData } from '../../data/remote-data'; +import { Workflowitem } from '../../submission/models/workflowitem.model'; + +/** + * An abstract model class for a TaskObject. + */ +export class TaskObject extends DSpaceObject implements CacheableObject, ListableObject { + + /** + * The task identifier + */ + id: string; + + /** + * The workflow step + */ + step: string; + + /** + * The task action type + */ + action: string; + + /** + * The workflowitem object whom this task is related + */ + workflowitem: Observable> | Workflowitem; +} diff --git a/src/app/core/tasks/pool-task-data.service.spec.ts b/src/app/core/tasks/pool-task-data.service.spec.ts new file mode 100644 index 0000000000..7f40c6e89c --- /dev/null +++ b/src/app/core/tasks/pool-task-data.service.spec.ts @@ -0,0 +1,70 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { CoreState } from '../core.reducers'; +import { PoolTaskDataService } from './pool-task-data.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +describe('PoolTaskDataService', () => { + let service: PoolTaskDataService; + let options: HttpOptions; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'pooltasks'; + const requestService = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store; + + function initTestService(): PoolTaskDataService { + return new PoolTaskDataService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + service = initTestService(); + options = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + }); + + describe('claimTask', () => { + + it('should call postToEndpoint method', () => { + spyOn(service, 'postToEndpoint'); + const scopeId = '1234'; + service.claimTask(scopeId); + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, {}, scopeId, options); + }); + }); +}); diff --git a/src/app/core/tasks/pool-task-data.service.ts b/src/app/core/tasks/pool-task-data.service.ts new file mode 100644 index 0000000000..1a93450d4d --- /dev/null +++ b/src/app/core/tasks/pool-task-data.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Observable } from 'rxjs'; +import { Store } from '@ngrx/store'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { RequestService } from '../data/request.service'; +import { PoolTask } from './models/pool-task-object.model'; +import { TasksService } from './tasks.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ProcessTaskResponse } from './models/process-task-response'; + +/** + * The service handling all REST requests for PoolTask + */ +@Injectable() +export class PoolTaskDataService extends TasksService { + + /** + * The endpoint link name + */ + protected linkPath = 'pooltasks'; + + /** + * When true, a new request is always dispatched + */ + protected forceBypassCache = true; + + /** + * Initialize instance variables + * + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {NormalizedObjectBuildService} dataBuildService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DSOChangeAnalyzer, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } + + /** + * Make a request to claim the given task + * + * @param scopeId + * The task id + * @return {Observable} + * Emit the server response + */ + public claimTask(scopeId: string): Observable { + return this.postToEndpoint(this.linkPath, {}, scopeId, this.makeHttpOptions()); + } +} diff --git a/src/app/core/tasks/task-response-parsing.service.ts b/src/app/core/tasks/task-response-parsing.service.ts new file mode 100644 index 0000000000..7445f9d267 --- /dev/null +++ b/src/app/core/tasks/task-response-parsing.service.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@angular/core'; + +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; + +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { ErrorResponse, RestResponse, TaskResponse } from '../cache/response.models'; + +/** + * Provides methods to parse response for a task request. + */ +@Injectable() +export class TaskResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = false; + + /** + * Initialize instance variables + * + * @param {GlobalConfig} EnvConfig + * @param {ObjectCacheService} objectCache + */ + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService,) { + super(); + } + + /** + * Parses data from the tasks endpoints + * + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (this.isSuccessStatus(data.statusCode)) { + return new TaskResponse( data.statusCode, data.statusText); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from server'), + { statusCode: data.statusCode, statusText: data.statusText } + ) + ); + } + } + +} diff --git a/src/app/core/tasks/tasks.service.spec.ts b/src/app/core/tasks/tasks.service.spec.ts new file mode 100644 index 0000000000..753ce2ddd5 --- /dev/null +++ b/src/app/core/tasks/tasks.service.spec.ts @@ -0,0 +1,130 @@ +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { TasksService } from './tasks.service'; +import { RequestService } from '../data/request.service'; +import { TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { TaskObject } from './models/task-object.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { compare, Operation } from 'fast-json-patch'; +import { NormalizedTaskObject } from './models/normalized-task-object.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +const LINK_NAME = 'test'; + +/* tslint:disable:max-classes-per-file */ +class TestTask extends TaskObject { +} + +class TestService extends TasksService { + protected linkPath = LINK_NAME; + protected forceBypassCache = true; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } +} + +class NormalizedTestTaskObject extends NormalizedTaskObject { +} + +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: NormalizedTestTaskObject, object2: NormalizedTestTaskObject): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } + +} +/* tslint:enable:max-classes-per-file */ + +describe('TasksService', () => { + let scheduler: TestScheduler; + let service: TestService; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'testTask'; + const requestService = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = new DummyChangeAnalyzer() as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store; + + function initTestService(): TestService { + return new TestService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + + }); + + describe('postToEndpoint', () => { + + it('should configure a new TaskPostRequest', () => { + const expected = new TaskPostRequest(requestService.generateRequestId(), `${taskEndpoint}/${linkPath}`, {}); + scheduler.schedule(() => service.postToEndpoint('testTask', {}).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('deleteById', () => { + + it('should configure a new TaskDeleteRequest', () => { + const scopeId = '1234'; + const expected = new TaskDeleteRequest(requestService.generateRequestId(), `${taskEndpoint}/${linkPath}/${scopeId}`, null); + scheduler.schedule(() => service.deleteById('testTask', scopeId).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + +}); diff --git a/src/app/core/tasks/tasks.service.ts b/src/app/core/tasks/tasks.service.ts new file mode 100644 index 0000000000..f39b144c6a --- /dev/null +++ b/src/app/core/tasks/tasks.service.ts @@ -0,0 +1,125 @@ +import { HttpHeaders } from '@angular/common/http'; + +import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map, mergeMap, tap } from 'rxjs/operators'; + +import { DataService } from '../data/data.service'; +import { DeleteRequest, FindAllOptions, PostRequest, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { ProcessTaskResponse } from './models/process-task-response'; +import { RemoteDataError } from '../data/remote-data-error'; +import { getResponseFromEntry } from '../shared/operators'; +import { ErrorResponse, MessageResponse, RestResponse } from '../cache/response.models'; +import { CacheableObject } from '../cache/object-cache.reducer'; + +/** + * An abstract class that provides methods to handle task requests. + */ +export abstract class TasksService extends DataService { + + public getBrowseEndpoint(options: FindAllOptions): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Fetch a RestRequest + * + * @param requestId + * The base endpoint for the type of object + * @return Observable + * server response + */ + protected fetchRequest(requestId: string): Observable { + const responses = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + const errorResponses = responses.pipe( + filter((response: RestResponse) => !response.isSuccessful), + mergeMap((response: ErrorResponse) => observableOf( + new ProcessTaskResponse( + response.isSuccessful, + new RemoteDataError(response.statusCode, response.statusText, response.errorMessage) + )) + )); + const successResponses = responses.pipe( + filter((response: RestResponse) => response.isSuccessful), + map((response: MessageResponse) => new ProcessTaskResponse(response.isSuccessful)), + distinctUntilChanged() + ); + return observableMerge(errorResponses, successResponses); + } + + /** + * Create the HREF for a specific submission object based on its identifier + * + * @param endpoint + * The base endpoint for the type of object + * @param resourceID + * The identifier for the object + */ + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + /** + * Make a new post request + * + * @param linkPath + * The endpoint link name + * @param body + * The request body + * @param scopeId + * The task id to be removed + * @param options + * The HttpOptions object + * @return Observable + * server response + */ + public postToEndpoint(linkPath: string, body: any, scopeId?: string, options?: HttpOptions): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + distinctUntilChanged(), + map((endpointURL: string) => new TaskPostRequest(requestId, endpointURL, body, options)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap((request: PostRequest) => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Delete an existing task on the server + * + * @param linkPath + * The endpoint link name + * @param scopeId + * The task id to be removed + * @param options + * The HttpOptions object + * @return Observable + * server response + */ + public deleteById(linkPath: string, scopeId: string, options?: HttpOptions): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkPath || this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + map((endpointURL: string) => new TaskDeleteRequest(requestId, endpointURL, null, options)), + tap((request: DeleteRequest) => this.requestService.configure(request)), + flatMap((request: DeleteRequest) => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Create a new HttpOptions + */ + protected makeHttpOptions() { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + return options; + } +} diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index c7f41a07a3..fec75b2fd3 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -1,5 +1,6 @@