diff --git a/config/environment.default.js b/config/environment.default.js index a6ef738f41..f70f132fa4 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -18,9 +18,16 @@ module.exports = { // Caching settings cache: { // NOTE: how long should objects be cached for by default - msToLive: 15 * 60 * 1000, // 15 minutes + msToLive: { + default: 15 * 60 * 1000, // 15 minutes + }, // msToLive: 1000, // 15 minutes - control: 'max-age=60' // revalidate browser + control: 'max-age=60', // revalidate browser + autoSync: { + defaultTime: 0, + maxBufferSize: 100, + timePerMethod: {'PATCH': 3} //time in seconds + } }, // Form settings form: { @@ -41,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, @@ -52,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 91117723d1..46eeb7be2f 100644 --- a/package.json +++ b/package.json @@ -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,13 +89,15 @@ "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", "core-js": "^2.5.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", @@ -110,7 +112,7 @@ "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", @@ -118,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", @@ -130,6 +133,7 @@ "devDependencies": { "@angular/compiler": "^6.1.4", "@angular/compiler-cli": "^6.1.4", + "@fortawesome/fontawesome-free": "^5.5.0", "@ngrx/entity": "^6.1.0", "@ngrx/schematics": "^6.1.0", "@ngrx/store-devtools": "^6.1.0", @@ -140,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", @@ -160,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", @@ -182,9 +188,10 @@ "karma-webdriver-launcher": "1.0.5", "karma-webpack": "3.0.0", "ngrx-store-freeze": "^0.2.4", - "node-sass": "^4.7.2", + "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 cf87e423a7..992e35d5c9 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -13,6 +13,38 @@ "head": "Recent Submissions" } } + }, + "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" + } } }, "community": { @@ -22,6 +54,39 @@ }, "sub-collection-list": { "head": "Collections of this Community" + }, + "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": { @@ -58,6 +123,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", @@ -73,6 +139,10 @@ "label": "Withdraw item from the repository", "button": "Withdraw..." }, + "reinstate": { + "label": "Reinstate item into the repository", + "button": "Reinstate..." + }, "move": { "label": "Move item to another collection", "button": "Move..." @@ -81,6 +151,10 @@ "label": "Make item private", "button": "Make it private..." }, + "public": { + "label": "Make item public", + "button": "Make it public..." + }, "delete": { "label": "Completely expunge item", "button": "Permanently delete" @@ -92,16 +166,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" } }, "move": { @@ -116,19 +194,115 @@ "cancel": "Cancel", "success": "The item has been moved succesfully", "error": "An error occured when attempting to move the item" + }, + "modify.overview": { + "field": "Field", + "value": "Value", + "language": "Language" + }, + "withdraw": { + "header": "Withdraw item: {{ id }}", + "description": "Are you sure this item should be withdrawn from the archive?", + "confirm": "Withdraw", + "cancel": "Cancel", + "success": "The item was withdrawn successfully", + "error": "An error occurred while withdrawing the item" + }, + "reinstate": { + "header": "Reinstate item: {{ id }}", + "description": "Are you sure this item should be reinstated to the archive?", + "confirm": "Reinstate", + "cancel": "Cancel", + "success": "The item was reinstated successfully", + "error": "An error occurred while reinstating the item" + }, + "private": { + "header": "Make item private: {{ id }}", + "description": "Are you sure this item should be made private in the archive?", + "confirm": "Make it Private", + "cancel": "Cancel", + "success": "The item is now private", + "error": "An error occurred while making the item private" + }, + "public": { + "header": "Make item public: {{ id }}", + "description": "Are you sure this item should be made public in the archive?", + "confirm": "Make it Public", + "cancel": "Cancel", + "success": "The item is now public", + "error": "An error occurred while making the item public" + }, + "delete": { + "header": "Delete item: {{ id }}", + "description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.", + "confirm": "Delete", + "cancel": "Cancel", + "success": "The item has been deleted", + "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." + } + } } } }, "nav": { - "home": "Home", + "browse": { + "header": "All of DSpace" + }, + "community-browse": { + "header": "By Community" + }, + "statistics": { + "header": "Statistics" + }, "login": "Log In", - "logout": "Log Out" + "logout": "Log Out", + "language": "Language switch", + "search": "Search" }, "pagination": { "results-per-page": "Results Per Page", "sort-direction": "Sort Options", "showing": { - "label": "Now showing items ", + "label": "Now showing ", "detail": "{{ range }} of {{ total }}" } }, @@ -227,7 +401,46 @@ } }, "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": { @@ -235,11 +448,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." } @@ -248,13 +468,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": { @@ -278,17 +525,99 @@ } } }, + "menu": { + "header": { + "admin": "Admin", + "image": { + "logo": "Repository logo" + } + }, + "section": { + "pin": "Pin sidebar", + "unpin": "Unpin sidebar", + "new": "New", + "new_community": "Community", + "new_collection": "Collection", + "new_item": "Item", + "new_item_version": "Item Version", + "edit": "Edit", + "edit_community": "Community", + "edit_collection": "Collection", + "edit_item": "Item", + "import": "Import", + "import_metadata": "Metadata", + "import_batch": "Batch Import (ZIP)", + "export": "Export", + "export_community": "Community", + "export_collection": "Collection", + "export_item": "Item", + "export_metadata": "Metadata", + "access_control": "Access Control", + "access_control_people": "People", + "access_control_groups": "Groups", + "access_control_authorizations": "Authorizations", + "find": "Find", + "find_items": "Items", + "find_withdrawn_items": "Withdrawn Items", + "find_private_items": "Private Items", + "registries": "Registries", + "registries_metadata": "Metadata", + "registries_format": "Format", + "curation_task": "Curation Task", + "statistics_task": "Statistics Task", + "control_panel": "Control Panel", + "browse_global": "All of DSpace", + "browse_global_communities_and_collections": "Communities & Collections", + "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", + "browse_community_by_author": "By Author", + "browse_community_by_title": "By Title", + "icon": { + "pin": "Pin sidebar", + "unpin": "Unpin sidebar", + "new": "New menu section", + "edit": "Edit menu section", + "import": "Import menu section", + "export": "Export menu section", + "access_control": "Access Control menu section", + "find": "Find menu section", + "registries": "Registries menu section", + "curation_task": "Curation Task menu section", + "statistics_task": "Statistics Task menu section", + "control_panel": "Control Panel menu section" + }, + "toggle": { + "new": "Toggle New section", + "edit": "Toggle Edit section", + "import": "Toggle Import section", + "export": "Toggle Export section", + "access_control": "Toggle Access Control section", + "find": "Toggle Find section", + "registries": "Toggle Registries section", + "curation_task": "Toggle Curation Task section", + "statistics_task": "Toggle Statistics Task section", + "control_panel": "Toggle Control Panel section" + } + } + }, "loading": { "default": "Loading...", "top-level-communities": "Loading top-level communities...", "community": "Loading community...", "collection": "Loading collection...", "sub-collections": "Loading sub-collections...", + "sub-communities": "Loading sub-communities...", "recent-submissions": "Loading recent submissions...", "item": "Loading item...", "objects": "Loading...", "search-results": "Loading search results...", - "browse-by": "Loading items..." + "browse-by": "Loading items...", + "browse-by-page": "Loading page..." }, "error": { "default": "Error", @@ -296,6 +625,7 @@ "community": "Error fetching community", "collection": "Error fetching collection", "sub-collections": "Error fetching sub-collections", + "sub-communities": "Error fetching sub-communities", "recent-submissions": "Error fetching recent submissions", "item": "Error fetching item", "objects": "Error fetching objects", @@ -306,13 +636,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...", @@ -321,7 +663,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", @@ -348,5 +692,128 @@ "errors": { "invalid-user": "Invalid email address or password." } + }, + "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?" + } + } + } + } + }, + "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/dspace-logo-mini.svg b/resources/images/dspace-logo-mini.svg new file mode 100644 index 0000000000..6ca41addc8 --- /dev/null +++ b/resources/images/dspace-logo-mini.svg @@ -0,0 +1,23 @@ + + + + + + diff --git a/resources/images/dspace-logo.png b/resources/images/dspace-logo.png index 5bb0b36add..40b59feeaa 100644 Binary files a/resources/images/dspace-logo.png and b/resources/images/dspace-logo.png differ diff --git a/resources/images/dspace-logo.svg b/resources/images/dspace-logo.svg new file mode 100644 index 0000000000..60df1ed46d --- /dev/null +++ b/resources/images/dspace-logo.svg @@ -0,0 +1,37 @@ + + + + + + + + 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 b2cc5129ce..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,30 +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 { SortOptions } from '../../../core/cache/models/sort-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) { } @@ -34,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-section/admin-sidebar-section.component.html b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html new file mode 100644 index 0000000000..e72a17aac1 --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.scss b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.scss new file mode 100644 index 0000000000..88eb98509a --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables.scss'; \ No newline at end of file diff --git a/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts new file mode 100644 index 0000000000..30c57c17ea --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts @@ -0,0 +1,60 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MenuService } from '../../../shared/menu/menu.service'; +import { MenuServiceStub } from '../../../shared/testing/menu-service-stub'; +import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service'; +import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service-stub'; +import { Component } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { AdminSidebarSectionComponent } from './admin-sidebar-section.component'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('AdminSidebarSectionComponent', () => { + let component: AdminSidebarSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + const iconString = 'test'; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [AdminSidebarSectionComponent, TestComponent], + providers: [ + { provide: 'sectionDataProvider', useValue: { model: { link: 'google.com' }, icon: iconString } }, + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + ] + }).overrideComponent(AdminSidebarSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminSidebarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set the right icon', () => { + const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas')); + expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { +} diff --git a/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts new file mode 100644 index 0000000000..a19a1f95e4 --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts @@ -0,0 +1,34 @@ +import { Component, Inject, Injector, OnInit } from '@angular/core'; +import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component'; +import { MenuID } from '../../../shared/menu/initial-menus-state'; +import { MenuService } from '../../../shared/menu/menu.service'; +import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; +import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; +import { MenuSection } from '../../../shared/menu/menu.reducer'; + +/** + * Represents a non-expandable section in the admin sidebar + */ +@Component({ + selector: 'ds-admin-sidebar-section', + templateUrl: './admin-sidebar-section.component.html', + styleUrls: ['./admin-sidebar-section.component.scss'], + +}) +@rendersSectionForMenu(MenuID.ADMIN, false) +export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit { + + /** + * This section resides in the Admin Sidebar + */ + menuID: MenuID = MenuID.ADMIN; + itemModel; + constructor(@Inject('sectionDataProvider') menuSection: MenuSection, protected menuService: MenuService, protected injector: Injector,) { + super(menuSection, menuService, injector); + this.itemModel = menuSection.model as LinkMenuItemModel; + } + + ngOnInit(): void { + super.ngOnInit(); + } +} diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.html b/src/app/+admin/admin-sidebar/admin-sidebar.component.html new file mode 100644 index 0000000000..fc9e707bcd --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.html @@ -0,0 +1,52 @@ + \ No newline at end of file diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.scss b/src/app/+admin/admin-sidebar/admin-sidebar.component.scss new file mode 100644 index 0000000000..9236cd2a0d --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.scss @@ -0,0 +1,77 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; +$icon-z-index: 10; + +:host { + left: 0; + top: 0; + height: 100vh; + flex: 1 1 auto; + nav { + height: 100%; + flex-direction: column; + > div { + width: 100%; + &.sidebar-top-level-items { + flex: 1; + overflow: auto; + @include dark-scrollbar; + } + } + + &.inactive ::ng-deep .sidebar-collapsible { + margin-left: -#{$sidebar-items-width}; + } + + .navbar-nav { + .admin-menu-header { + background-color: $admin-sidebar-header-bg; + .logo-wrapper { + img { + height: 20px; + } + } + .section-header-text { + line-height: 1.5; + } + + } + } + + + ::ng-deep { + .navbar-nav { + .sidebar-section { + display: flex; + align-content: stretch; + background-color: $dark; + .nav-item { + padding-top: $spacer; + padding-bottom: $spacer; + } + .shortcut-icon { + padding-left: $icon-padding; + padding-right: $icon-padding; + } + .shortcut-icon, .icon-wrapper { + background-color: inherit; + z-index: $icon-z-index; + } + .sidebar-collapsible { + width: $sidebar-items-width; + position: relative; + a { + padding-right: $spacer; + width: 100%; + } + } + &.active > .sidebar-collapsible > .nav-link { + color: $navbar-dark-active-color; + } + } + } + } + + } + +} \ No newline at end of file diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts new file mode 100644 index 0000000000..590caaaccf --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -0,0 +1,160 @@ +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { AdminSidebarComponent } from './admin-sidebar.component'; +import { MenuService } from '../../shared/menu/menu.service'; +import { MenuServiceStub } from '../../shared/testing/menu-service-stub'; +import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; +import { CSSVariableServiceStub } from '../../shared/testing/css-variable-service-stub'; +import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +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; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule], + declarations: [AdminSidebarComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: AuthService, useClass: AuthServiceStub }, + { + provide: NgbModal, useValue: { + open: () => {/*comment*/} + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(AdminSidebarComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + } + }).compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(AdminSidebarComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + comp.sections = observableOf([]); + fixture.detectChanges(); + }); + + describe('startSlide', () => { + describe('when expanding', () => { + beforeEach(() => { + comp.sidebarClosed = true; + comp.startSlide({ toState: 'expanded' } as any); + }); + + it('should set the sidebarClosed to false', () => { + expect(comp.sidebarClosed).toBeFalsy(); + }) + }); + + describe('when collapsing', () => { + beforeEach(() => { + comp.sidebarClosed = false; + comp.startSlide({ toState: 'collapsed' } as any); + }); + + it('should set the sidebarOpen to false', () => { + expect(comp.sidebarOpen).toBeFalsy(); + }) + }) + }); + + describe('finishSlide', () => { + describe('when expanding', () => { + beforeEach(() => { + comp.sidebarClosed = true; + comp.startSlide({ fromState: 'expanded' } as any); + }); + + it('should set the sidebarClosed to true', () => { + expect(comp.sidebarClosed).toBeTruthy(); + }) + }); + + describe('when collapsing', () => { + beforeEach(() => { + comp.sidebarClosed = false; + comp.startSlide({ fromState: 'collapsed' } as any); + }); + + it('should set the sidebarOpen to true', () => { + expect(comp.sidebarOpen).toBeTruthy(); + }) + }) + }); + + describe('when the collapse icon is clicked', () => { + beforeEach(() => { + spyOn(menuService, 'toggleMenu'); + const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon')); + sidebarToggler.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + }); + + it('should call toggleMenu on the menuService', () => { + expect(menuService.toggleMenu).toHaveBeenCalled(); + }); + }); + + describe('when the collapse link is clicked', () => { + 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: () => {/**/ + } + }); + }); + + it('should call toggleMenu on the menuService', () => { + expect(menuService.toggleMenu).toHaveBeenCalled(); + }); + }); + + describe('when the the mouse enters the nav tag', () => { + 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: () => {/**/ + } + }); + tick(99); + expect(menuService.expandMenuPreview).not.toHaveBeenCalled(); + tick(1); + expect(menuService.expandMenuPreview).toHaveBeenCalled(); + })); + }); + + describe('when the the mouse leaves the nav tag', () => { + 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: () => {/**/ + } + }); + tick(399); + expect(menuService.collapseMenuPreview).not.toHaveBeenCalled(); + tick(1); + expect(menuService.collapseMenuPreview).toHaveBeenCalled(); + })); + }); +}); diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts new file mode 100644 index 0000000000..f148627297 --- /dev/null +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -0,0 +1,499 @@ +import { Component, Injector, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +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'; +import { MenuComponent } from '../../shared/menu/menu.component'; +import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model'; +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 + */ +@Component({ + selector: 'ds-admin-sidebar', + templateUrl: './admin-sidebar.component.html', + styleUrls: ['./admin-sidebar.component.scss'], + animations: [slideHorizontal, slideSidebar] +}) +export class AdminSidebarComponent extends MenuComponent implements OnInit { + /** + * The menu ID of the Navbar is PUBLIC + * @type {MenuID.ADMIN} + */ + menuID = MenuID.ADMIN; + + /** + * Observable that emits the width of the collapsible menu sections + */ + sidebarWidth: Observable; + + /** + * Is true when the sidebar is open, is false when the sidebar is animating or closed + * @type {boolean} + */ + sidebarOpen = true; // Open in UI, animation finished + + /** + * Is true when the sidebar is closed, is false when the sidebar is animating or open + * @type {boolean} + */ + sidebarClosed = !this.sidebarOpen; // Closed in UI, animation finished + + /** + * Emits true when either the menu OR the menu's preview is expanded, else emits false + */ + sidebarExpanded: Observable; + + constructor(protected menuService: MenuService, + protected injector: Injector, + private variableService: CSSVariableService, + private authService: AuthService, + private modalService: NgbModal + ) { + super(menuService, injector); + } + + /** + * Set and calculate all initial values of the instance variables + */ + ngOnInit(): void { + this.createMenu(); + super.ngOnInit(); + this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth'); + this.authService.isAuthenticated() + .subscribe((loggedIn: boolean) => { + if (loggedIn) { + this.menuService.showMenu(this.menuID); + } + }); + this.menuCollapsed.pipe(first()) + .subscribe((collapsed: boolean) => { + this.sidebarOpen = !collapsed; + this.sidebarClosed = collapsed; + }); + this.sidebarExpanded = combineLatestObservable(this.menuCollapsed, this.menuPreviewCollapsed) + .pipe( + map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed)) + ); + } + + /** + * Initialize all menu sections and items for this menu + */ + private createMenu() { + const menuList = [ + /* News */ + { + id: 'new', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.new' + } as TextMenuItemModel, + icon: 'plus-circle', + index: 0 + }, + { + id: 'new_community', + parentID: 'new', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_community', + function: () => { + this.modalService.open(CreateCommunityParentSelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'new_collection', + parentID: 'new', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.new_collection', + 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, + }, + { + id: 'new_item_version', + parentID: 'new', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.new_item_version', + link: '#' + } as LinkMenuItemModel, + }, + + /* Edit */ + { + id: 'edit', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.edit' + } as TextMenuItemModel, + icon: 'pencil-alt', + index: 1 + }, + { + id: 'edit_community', + parentID: 'edit', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_community', + function: () => { + this.modalService.open(EditCommunitySelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'edit_collection', + parentID: 'edit', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_collection', + function: () => { + this.modalService.open(EditCollectionSelectorComponent); + } + } as OnClickMenuItemModel, + }, + { + id: 'edit_item', + parentID: 'edit', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.edit_item', + function: () => { + this.modalService.open(EditItemSelectorComponent); + } + } as OnClickMenuItemModel, + }, + + /* Import */ + { + id: 'import', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.import' + } as TextMenuItemModel, + icon: 'sign-in-alt', + index: 2 + }, + { + id: 'import_metadata', + parentID: 'import', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.import_metadata', + link: '#' + } as LinkMenuItemModel, + }, + { + id: 'import_batch', + parentID: 'import', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.import_batch', + link: '#' + } as LinkMenuItemModel, + }, + /* Export */ + { + id: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.export' + } as TextMenuItemModel, + icon: 'sign-out-alt', + index: 3 + }, + { + id: 'export_community', + parentID: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.export_community', + link: '#' + } as LinkMenuItemModel, + }, + { + id: 'export_collection', + parentID: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.export_collection', + link: '#' + } as LinkMenuItemModel, + }, + { + id: 'export_item', + parentID: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.export_item', + link: '#' + } as LinkMenuItemModel, + }, { + id: 'export_metadata', + parentID: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.export_metadata', + link: '#' + } as LinkMenuItemModel, + }, + + /* Access Control */ + { + id: 'access_control', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.access_control' + } as TextMenuItemModel, + icon: 'key', + index: 4 + }, + { + id: 'access_control_people', + parentID: 'access_control', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_people', + link: '#' + } as LinkMenuItemModel, + }, + { + id: 'access_control_groups', + parentID: 'access_control', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_groups', + link: '#' + } as LinkMenuItemModel, + }, + { + id: 'access_control_authorizations', + parentID: 'access_control', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_authorizations', + link: '#' + } as LinkMenuItemModel, + }, + + /* Search */ + { + id: 'find', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.find' + } as TextMenuItemModel, + icon: 'search', + index: 5 + }, + { + id: 'find_items', + parentID: 'find', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.find_items', + link: '/search' + } as LinkMenuItemModel, + }, + { + id: 'find_withdrawn_items', + parentID: 'find', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.find_withdrawn_items', + link: '#' + } as LinkMenuItemModel, + }, + { + id: 'find_private_items', + parentID: 'find', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.find_private_items', + link: '/admin/items' + } as LinkMenuItemModel, + }, + + /* Registries */ + { + id: 'registries', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.registries' + } as TextMenuItemModel, + icon: 'list', + index: 6 + }, + { + id: 'registries_metadata', + parentID: 'registries', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.registries_metadata', + link: 'admin/registries/metadata' + } as LinkMenuItemModel, + }, + { + id: 'registries_format', + parentID: 'registries', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.registries_format', + link: 'admin/registries/bitstream-formats' + } as LinkMenuItemModel, + }, + + /* Curation tasks */ + { + id: 'curation_tasks', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.curation_task', + link: '/curation' + } as LinkMenuItemModel, + icon: 'filter', + index: 7 + }, + + /* Statistics */ + { + id: 'statistics_task', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics_task', + link: '#' + } as LinkMenuItemModel, + icon: 'chart-bar', + index: 8 + }, + + /* Control Panel */ + { + id: 'control_panel', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.control_panel', + link: '#' + } as LinkMenuItemModel, + icon: 'cogs', + index: 9 + }, + ]; + menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection)); + + } + + /** + * Method to change this.collapsed to false when the slide animation ends and is sliding open + * @param event The animation event + */ + startSlide(event: any): void { + if (event.toState === 'expanded') { + this.sidebarClosed = false; + } else if (event.toState === 'collapsed') { + this.sidebarOpen = false; + } + } + + /** + * Method to change this.collapsed to false when the slide animation ends and is sliding open + * @param event The animation event + */ + finishSlide(event: any): void { + if (event.fromState === 'expanded') { + this.sidebarClosed = true; + } else if (event.fromState === 'collapsed') { + this.sidebarOpen = true; + } + } +} diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html new file mode 100644 index 0000000000..808683910e --- /dev/null +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss new file mode 100644 index 0000000000..779cba09d9 --- /dev/null +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss @@ -0,0 +1,21 @@ +@import '../../../../styles/variables.scss'; + +::ng-deep { + .fa-chevron-right { + padding-left: $spacer/2; + font-size: 0.5rem; + line-height: 3; + } + + .sidebar-sub-level-items { + list-style: disc; + color: $navbar-dark-color; + overflow: hidden; + + } + + .sidebar-collapsible { + display: flex; + flex-direction: column; + } +} \ No newline at end of file diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts new file mode 100644 index 0000000000..787386932a --- /dev/null +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts @@ -0,0 +1,84 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExpandableAdminSidebarSectionComponent } from './expandable-admin-sidebar-section.component'; +import { MenuService } from '../../../shared/menu/menu.service'; +import { MenuServiceStub } from '../../../shared/testing/menu-service-stub'; +import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service'; +import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service-stub'; +import { of as observableOf } from 'rxjs'; +import { Component } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('ExpandableAdminSidebarSectionComponent', () => { + let component: ExpandableAdminSidebarSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + const iconString = 'test'; + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, TranslateModule.forRoot()], + declarations: [ExpandableAdminSidebarSectionComponent, TestComponent], + providers: [ + { provide: 'sectionDataProvider', useValue: {icon: iconString} }, + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + ] + }).overrideComponent(ExpandableAdminSidebarSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set the right icon', () => { + const icon = fixture.debugElement.query(By.css('.icon-wrapper')).query(By.css('i.fas')); + expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString); + }); + + describe('when the icon is clicked', () => { + beforeEach(() => { + spyOn(menuService, 'toggleActiveSection'); + const sidebarToggler = fixture.debugElement.query(By.css('a.shortcut-icon')); + sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}}); + }); + + it('should call toggleActiveSection on the menuService', () => { + expect(menuService.toggleActiveSection).toHaveBeenCalled(); + }); + }); + + describe('when the header text is clicked', () => { + beforeEach(() => { + spyOn(menuService, 'toggleActiveSection'); + const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-collapsible')).query(By.css('a')); + sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}}); + }); + + it('should call toggleActiveSection on the menuService', () => { + expect(menuService.toggleActiveSection).toHaveBeenCalled(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { +} 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 new file mode 100644 index 0000000000..4921be77e2 --- /dev/null +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -0,0 +1,69 @@ +import { Component, Inject, Injector, OnInit } from '@angular/core'; +import { rotate } from '../../../shared/animations/rotate'; +import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component'; +import { slide } from '../../../shared/animations/slide'; +import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service'; +import { bgColor } from '../../../shared/animations/bgColor'; +import { MenuID } from '../../../shared/menu/initial-menus-state'; +import { MenuService } from '../../../shared/menu/menu.service'; +import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; + +/** + * Represents a expandable section in the sidebar + */ +@Component({ + selector: 'ds-expandable-admin-sidebar-section', + 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 { + /** + * This section resides in the Admin Sidebar + */ + menuID = MenuID.ADMIN; + + /** + * The background color of the section when it's active + */ + sidebarActiveBg; + + /** + * Emits true when the sidebar is currently collapsed, true when it's expanded + */ + sidebarCollapsed: Observable; + + /** + * Emits true when the sidebar's preview is currently collapsed, true when it's expanded + */ + sidebarPreviewCollapsed: Observable; + + /** + * Emits true when the menu section is expanded, else emits false + * This is true when the section is active AND either the sidebar or it's preview is open + */ + expanded: Observable; + + constructor(@Inject('sectionDataProvider') menuSection, protected menuService: MenuService, + private variableService: CSSVariableService, protected injector: Injector) { + super(menuSection, menuService, injector); + } + + /** + * Set initial values for instance variables + */ + ngOnInit(): void { + super.ngOnInit(); + this.sidebarActiveBg = this.variableService.getVariable('adminSidebarActiveBg'); + this.sidebarCollapsed = this.menuService.isMenuCollapsed(this.menuID); + this.sidebarPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID); + this.expanded = combineLatestObservable(this.active, this.sidebarCollapsed, this.sidebarPreviewCollapsed) + .pipe( + map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed))) + ); + } +} diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index b979813376..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 - ] + 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 ca56bca2cd..cdbd7650b2 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -3,10 +3,57 @@ import { RouterModule } from '@angular/router'; import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageResolver } from './collection-page.resolver'; +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'; + +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, @@ -19,6 +66,7 @@ import { CollectionPageResolver } from './collection-page.resolver'; ], 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..6265b223d8 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -7,6 +7,8 @@ + + diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 59cf83777f..7c4f2b92ac 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Observable, Subscription } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; 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'; @@ -15,7 +15,7 @@ import { Item } from '../core/shared/item.model'; import { fadeIn, fadeInOut } from '../shared/animations/fade'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { filter, flatMap, map } from 'rxjs/operators'; +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'; @@ -55,7 +55,8 @@ export class CollectionPageComponent implements OnInit, OnDestroy { ngOnInit(): void { this.collectionRD$ = this.route.data.pipe( - map((data) => data.collection) + map((data) => data.collection), + tap((data) => this.collectionId = data.payload.id) ); this.logoRD$ = this.collectionRD$.pipe( map((rd: RemoteData) => rd.payload), @@ -75,8 +76,8 @@ export class CollectionPageComponent implements OnInit, OnDestroy { pagination: pagination, sort: this.sortConfig }); - }) - ); + })); + } updatePage(searchOptions) { diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts index 85462e67a3..f0e4138d2d 100644 --- a/src/app/+collection-page/collection-page.module.ts +++ b/src/app/+collection-page/collection-page.module.ts @@ -5,17 +5,24 @@ import { SharedModule } from '../shared/shared.module'; import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageRoutingModule } from './collection-page-routing.module'; +import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; +import { CollectionFormComponent } from './collection-form/collection-form.component'; import { SearchPageModule } from '../+search-page/search-page.module'; +import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component'; +import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; @NgModule({ imports: [ CommonModule, SharedModule, - SearchPageModule, CollectionPageRoutingModule ], declarations: [ CollectionPageComponent, + CreateCollectionPageComponent, + EditCollectionPageComponent, + DeleteCollectionPageComponent, + CollectionFormComponent ] }) 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/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..a3978a5e43 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts @@ -0,0 +1,26 @@ +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 { NormalizedCollection } from '../../core/cache/models/normalized-collection.model'; +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 637e37af0c..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 a6e1d5376c..2035faf988 100644 --- a/src/app/+community-page/community-page.component.ts +++ b/src/app/+community-page/community-page.component.ts @@ -1,4 +1,4 @@ -import {mergeMap, filter, map} from 'rxjs/operators'; +import { mergeMap, filter, map } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @@ -21,11 +21,19 @@ 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>; - logoRD$: Observable>; - private subs: Subscription[] = []; + /** + * The logo of this community + */ + logoRD$: Observable>; constructor( private communityDataService: CommunityDataService, private metadata: MetadataService, @@ -42,8 +50,4 @@ export class CommunityPageComponent implements OnInit, OnDestroy { 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 e00c3910c5..6d63cadcc8 100644 --- a/src/app/+community-page/community-page.module.ts +++ b/src/app/+community-page/community-page.module.ts @@ -6,6 +6,11 @@ import { SharedModule } from '../shared/shared.module'; 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: [ @@ -16,8 +21,14 @@ import { CommunityPageRoutingModule } from './community-page-routing.module'; declarations: [ CommunityPageComponent, CommunityPageSubCollectionListComponent, + CommunityPageSubCommunityListComponent, + CreateCommunityPageComponent, + EditCommunityPageComponent, + DeleteCommunityPageComponent, + CommunityFormComponent ] }) + export class CommunityPageModule { } 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-collection-list/community-page-sub-collection-list.component.html b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html index 12c2578d9c..9156a99b18 100644 --- a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html @@ -1,5 +1,5 @@ -
+

{{'community.sub-collection-list.head' | translate}}

  • diff --git a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.html b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.html new file mode 100644 index 0000000000..6cd62ba48d --- /dev/null +++ b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.html @@ -0,0 +1,15 @@ + +
    +

    {{'community.sub-community-list.head' | translate}}

    + +
    + + +
    diff --git a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.scss b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.scss new file mode 100644 index 0000000000..50be6f5ad0 --- /dev/null +++ b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.scss @@ -0,0 +1 @@ +@import '../../../styles/variables.scss'; 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 new file mode 100644 index 0000000000..3e6190ae6d --- /dev/null +++ b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -0,0 +1,90 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {TranslateModule} from '@ngx-translate/core'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {CommunityPageSubCommunityListComponent} from './community-page-sub-community-list.component'; +import {Community} from '../../core/shared/community.model'; +import {RemoteData} from '../../core/data/remote-data'; +import {PaginatedList} from '../../core/data/paginated-list'; +import {PageInfo} from '../../core/shared/page-info.model'; +import {SharedModule} from '../../shared/shared.module'; +import {RouterTestingModule} from '@angular/router/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {By} from '@angular/platform-browser'; +import {of as observableOf, Observable } from 'rxjs'; + +describe('SubCommunityList Component', () => { + let comp: CommunityPageSubCommunityListComponent; + let fixture: ComponentFixture; + + const subcommunities = [Object.assign(new Community(), { + id: '123456789-1', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 1' } + ] + } + }), + Object.assign(new Community(), { + id: '123456789-2', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 2' } + ] + } + }) + ]; + + const emptySubCommunitiesCommunity = Object.assign(new Community(), { + 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: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' } + ] + }, + subcommunities: observableOf(new RemoteData(true, true, true, + undefined, new PaginatedList(new PageInfo(), subcommunities))) + }) + ; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, + RouterTestingModule.withRoutes([]), + NoopAnimationsModule], + declarations: [CommunityPageSubCommunityListComponent], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityPageSubCommunityListComponent); + comp = fixture.componentInstance; + }); + + it('should display a list of subCommunities', () => { + comp.community = mockCommunity; + fixture.detectChanges(); + + const subComList = fixture.debugElement.queryAll(By.css('li')); + expect(subComList.length).toEqual(2); + expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); + expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); + }); + + it('should not display the header when subCommunities are empty', () => { + comp.community = emptySubCommunitiesCommunity; + fixture.detectChanges(); + + const subComHead = fixture.debugElement.queryAll(By.css('h2')); + expect(subComHead.length).toEqual(0); + }); +}); diff --git a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.ts new file mode 100644 index 0000000000..91f6d7bac1 --- /dev/null +++ b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.ts @@ -0,0 +1,26 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { RemoteData } from '../../core/data/remote-data'; +import { Community } from '../../core/shared/community.model'; + +import { fadeIn } from '../../shared/animations/fade'; +import { PaginatedList } from '../../core/data/paginated-list'; +import {Observable} from 'rxjs'; + +@Component({ + selector: 'ds-community-page-sub-community-list', + styleUrls: ['./community-page-sub-community-list.component.scss'], + templateUrl: './community-page-sub-community-list.component.html', + animations:[fadeIn] +}) +/** + * Component to render the sub-communities of a Community + */ +export class CommunityPageSubCommunityListComponent implements OnInit { + @Input() community: Community; + subCommunitiesRDObs: Observable>>; + + ngOnInit(): void { + this.subCommunitiesRDObs = this.community.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 ffc88be574..47ceaac90f 100644 --- a/src/app/+home-page/home-news/home-news.component.html +++ b/src/app/+home-page/home-news/home-news.component.html @@ -1,20 +1,19 @@
    -
    -
    - -
    +
    +
    -

    Welcome to DSpace

    -

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

    +

    Welcome to the DSpace 7 Preview Release

    +

    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.scss b/src/app/+home-page/home-news/home-news.component.scss index 4f4c6df128..3a0cbc7817 100644 --- a/src/app/+home-page/home-news/home-news.component.scss +++ b/src/app/+home-page/home-news/home-news.component.scss @@ -6,11 +6,11 @@ margin-bottom: -$content-spacing; } -.dspace-logo-container { - margin: 10px 20px 0px 20px; +.display-3 { + word-break: break-word; } -.dspace-logo-container img { - max-height: 110px; - max-width: 110px; +.dspace-logo { + height: 110px; + width: 110px; } 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/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 8e8c83ce5b..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'], @@ -17,9 +21,21 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c changeDetection: ChangeDetectionStrategy.OnPush, 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) { @@ -28,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-operators.spec.ts b/src/app/+item-page/edit-item-page/edit-item-operators.spec.ts new file mode 100644 index 0000000000..8086a62b8f --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-operators.spec.ts @@ -0,0 +1,35 @@ +import {RemoteData} from '../../core/data/remote-data'; +import {hot} from 'jasmine-marbles'; +import {Item} from '../../core/shared/item.model'; +import {findSuccessfulAccordingTo} from './edit-item-operators'; + +describe('findSuccessfulAccordingTo', () => { + let mockItem1; + let mockItem2; + let predicate; + + beforeEach(() => { + mockItem1 = new Item(); + mockItem1.isWithdrawn = true; + + mockItem2 = new Item(); + mockItem1.isWithdrawn = false; + + predicate = (rd: RemoteData) => rd.payload.isWithdrawn; + }); + it('should return first successful RemoteData Observable that complies to predicate', () => { + const testRD = { + a: new RemoteData(false, false, true, null, undefined), + b: new RemoteData(false, false, false, null, mockItem1), + c: new RemoteData(false, false, true, null, mockItem2), + d: new RemoteData(false, false, true, null, mockItem1), + e: new RemoteData(false, false, true, null, mockItem2), + }; + + const source = hot('abcde', testRD); + const result = source.pipe(findSuccessfulAccordingTo(predicate)); + + result.subscribe((value) => expect(value).toEqual(testRD.d)); + }); + +}); diff --git a/src/app/+item-page/edit-item-page/edit-item-operators.ts b/src/app/+item-page/edit-item-page/edit-item-operators.ts new file mode 100644 index 0000000000..26c593cac6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-operators.ts @@ -0,0 +1,13 @@ +import {RemoteData} from '../../core/data/remote-data'; +import {Observable} from 'rxjs'; +import {first} from 'rxjs/operators'; +import {getAllSucceededRemoteData} from '../../core/shared/operators'; + +/** + * Return first Observable of a RemoteData object that complies to the provided predicate + * @param predicate + */ +export const findSuccessfulAccordingTo = (predicate: (rd: RemoteData) => boolean) => + (source: Observable>): Observable> => + source.pipe(getAllSucceededRemoteData(), + first(predicate)); 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 de40239b3e..4ea47f08e7 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 {fadeIn, fadeInOut} from '../../shared/animations/fade'; -import {Observable} from 'rxjs'; -import {RemoteData} from '../../core/data/remote-data'; -import {Item} from '../../core/shared/item.model'; -import {map} from 'rxjs/operators'; +import { ActivatedRoute, Params, 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 09a5e1d588..e2f63ac5fc 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 @@ -1,25 +1,44 @@ 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 { EditItemPageComponent } from './edit-item-page.component'; import { ItemStatusComponent } from './item-status/item-status.component'; -import {ItemOperationComponent} from './item-operation/item-operation.component'; -import {ItemMoveComponent} from './item-move/item-move.component'; -import {SearchPageModule} from '../../+search-page/search-page.module'; +import { ItemOperationComponent } from './item-operation/item-operation.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 { ItemMoveComponent } from './item-move/item-move.component'; +/** + * Module that contains all components related to the Edit Item page administrator functionality + */ @NgModule({ imports: [ CommonModule, SharedModule, - EditItemPageRoutingModule, - SearchPageModule.forRoot(), ], declarations: [ EditItemPageComponent, ItemOperationComponent, - ItemMoveComponent, - ItemStatusComponent + AbstractSimpleItemActionComponent, + ModifyItemOverviewComponent, + ItemWithdrawComponent, + ItemReinstateComponent, + ItemPrivateComponent, + ItemPublicComponent, + ItemDeleteComponent, + ItemStatusComponent, + ItemMetadataComponent, + ItemBitstreamsComponent, + EditInPlaceFieldComponent, + ItemMoveComponent ] }) export class EditItemPageModule { 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 e9b0643cc1..c82025cf34 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 @@ -1,23 +1,109 @@ -import {ItemPageResolver} from '../item-page.resolver'; -import {NgModule} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {EditItemPageComponent} from './edit-item-page.component'; -import {ItemMoveComponent} from './item-move/item-move.component'; -import {URLCombiner} from '../../core/url-combiner/url-combiner'; -import {getItemEditPath} from '../item-page-routing.module'; +import { ItemPageResolver } from '../item-page.resolver'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EditItemPageComponent } from './edit-item-page.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 { ItemMoveComponent } from './item-move/item-move.component'; +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getItemEditPath } from '../item-page-routing.module'; + +const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; +const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; +const ITEM_EDIT_PRIVATE_PATH = 'private'; +const ITEM_EDIT_PUBLIC_PATH = 'public'; +const ITEM_EDIT_DELETE_PATH = 'delete'; const ITEM_EDIT_MOVE_PATH = 'move'; export function getItemEditMovePath(id: string) { return new URLCombiner(getItemEditPath(id), ITEM_EDIT_MOVE_PATH); + } +/** + * Routing module that handles the routing for the Edit Item page administrator functionality + */ @NgModule({ imports: [ RouterModule.forChild([ { path: '', 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: ITEM_EDIT_WITHDRAW_PATH, + component: ItemWithdrawComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_REINSTATE_PATH, + component: ItemReinstateComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_PRIVATE_PATH, + component: ItemPrivateComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_PUBLIC_PATH, + component: ItemPublicComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_DELETE_PATH, + component: ItemDeleteComponent, resolve: { item: ItemPageResolver } @@ -28,8 +114,7 @@ export function getItemEditMovePath(id: string) { resolve: { item: ItemPageResolver } - } - ]) + }]) ], providers: [ ItemPageResolver, 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 new file mode 100644 index 0000000000..6d435c8de8 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts @@ -0,0 +1,110 @@ +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; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; + +describe('ItemDeleteComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + delete: observableOf(true) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, mockItem) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + 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 }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemDeleteComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'delete\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.delete.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.delete.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.delete.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.delete.cancel'); + }); + + describe('performAction', () => { + it('should call delete function from the ItemDataService', () => { + spyOn(comp, 'notify'); + comp.performAction(); + expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem); + expect(comp.notify).toHaveBeenCalled(); + }); + }); + describe('notify', () => { + it('should navigate to the homepage on successful deletion of the item', () => { + comp.notify(true); + expect(routerStub.navigate).toHaveBeenCalledWith(['']); + }); + }); + describe('notify', () => { + it('should navigate to the item edit page on failed deletion of the item', () => { + 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 new file mode 100644 index 0000000000..2700b45475 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts @@ -0,0 +1,42 @@ +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({ + selector: 'ds-item-delete', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the item delete page + */ +export class ItemDeleteComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'delete'; + + /** + * Perform the delete action to the item + */ + performAction() { + this.itemDataService.delete(this.item).pipe(first()).subscribe( + (succeeded: boolean) => { + this.notify(succeeded); + } + ); + } + + /** + * When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page + * @param response + */ + notify(succeeded: boolean) { + if (succeeded) { + this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success')); + this.router.navigate(['']); + } else { + this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error')); + this.router.navigate([getItemEditPath(this.item.id)]); + } + } +} 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-move/item-move.component.spec.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts index eaf8e15fa4..8f95441bad 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts @@ -13,166 +13,168 @@ import {SearchService} from '../../../+search-page/search-service/search.service import {of as observableOf} from 'rxjs'; import {FormsModule} from '@angular/forms'; import {ItemDataService} from '../../../core/data/item-data.service'; -import {RestResponse} from '../../../core/cache/response-cache.models'; import {RemoteData} from '../../../core/data/remote-data'; import {PaginatedList} from '../../../core/data/paginated-list'; import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; - -let comp: ItemMoveComponent; -let fixture: ComponentFixture; - -const mockItem = Object.assign(new Item(), { - id: 'fake-id', - handle: 'fake/handle', - lastModified: '2018' -}); - -const itemPageUrl = `fake-url/${mockItem.id}`; -const routerStub = Object.assign(new RouterStub(), { - url: `${itemPageUrl}/edit` -}); - -const mockItemDataService = jasmine.createSpyObj({ - moveToCollection: observableOf(new RestResponse(true, '200')) -}); - -const mockItemDataServiceFail = jasmine.createSpyObj({ - moveToCollection: observableOf(new RestResponse(false, '500')) -}); - -const routeStub = { - data: observableOf({ - item: new RemoteData(false, false, true, null, { - id: 'item1' - }) - }) -}; - -const mockSearchService = { - search: () => { - return observableOf(new RemoteData(false, false, true, null, - new PaginatedList(null, [ - { - dspaceObject: { - name: 'Test collection 1', - uuid: 'collection1' - }, hitHighlights: {} - }, { - dspaceObject: { - name: 'Test collection 2', - uuid: 'collection2' - }, hitHighlights: {} - } - ]))); - } -}; - -const notificationsServiceStub = new NotificationsServiceStub(); +import { RestResponse } from '../../../core/cache/response.models'; describe('ItemMoveComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], - declarations: [ItemMoveComponent], - providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataService}, - {provide: NotificationsService, useValue: notificationsServiceStub}, - {provide: SearchService, useValue: mockSearchService}, - ], schemas: [ - CUSTOM_ELEMENTS_SCHEMA - ] - }).compileComponents(); - })); + let comp: ItemMoveComponent; + let fixture: ComponentFixture; - beforeEach(() => { - fixture = TestBed.createComponent(ItemMoveComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018' }); - it('should load suggestions', () => { - const expected = [ - { - displayValue: 'Test collection 1', - value: { - name: 'Test collection 1', - id: 'collection1', - } - }, - { - displayValue: 'Test collection 2', - value: { - name: 'Test collection 2', - id: 'collection2', - } - } - ]; - comp.collectionSearchResults.subscribe((value) => { - expect(value).toEqual(expected); - } - ); + const itemPageUrl = `fake-url/${mockItem.id}`; + const routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` }); - it('should get current url ', () => { - expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit'); - }); - it('should on click select the correct collection name and id', () => { - const data = { - name: 'Test collection 1', - id: 'collection1', - }; - comp.onClick(data); - expect(comp.selectedCollection).toEqual('Test collection 1'); - expect(comp.selectedCollectionId).toEqual('collection1'); + const mockItemDataService = jasmine.createSpyObj({ + moveToCollection: observableOf(new RestResponse(true, 200, 'Success')) }); - describe('moveCollection', () => { - it('should call itemDataService.moveToCollection', () => { - comp.itemId = 'item-id'; - comp.selectedCollectionId = 'selected-collection-id'; - comp.moveCollection(); - expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', 'selected-collection-id'); + const mockItemDataServiceFail = jasmine.createSpyObj({ + moveToCollection: observableOf(new RestResponse(false, 500, 'Internal server error')) + }); + + const routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'item1' + }) + }) + }; + + const mockSearchService = { + search: () => { + return observableOf(new RemoteData(false, false, true, null, + new PaginatedList(null, [ + { + dspaceObject: { + name: 'Test collection 1', + uuid: 'collection1' + }, hitHighlights: {} + }, { + dspaceObject: { + name: 'Test collection 2', + uuid: 'collection2' + }, hitHighlights: {} + } + ]))); + } + }; + + const notificationsServiceStub = new NotificationsServiceStub(); + + describe('ItemMoveComponent success', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemMoveComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + {provide: SearchService, useValue: mockSearchService}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemMoveComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); }); - it('should call notificationsService success message on success', () => { - spyOn(notificationsServiceStub, 'success'); + it('should load suggestions', () => { + const expected = [ + { + displayValue: 'Test collection 1', + value: { + name: 'Test collection 1', + id: 'collection1', + } + }, + { + displayValue: 'Test collection 2', + value: { + name: 'Test collection 2', + id: 'collection2', + } + } + ]; + + comp.collectionSearchResults.subscribe((value) => { + expect(value).toEqual(expected); + } + ); + }); + it('should get current url ', () => { + expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit'); + }); + it('should on click select the correct collection name and id', () => { + const data = { + name: 'Test collection 1', + id: 'collection1', + }; + comp.onClick(data); + + expect(comp.selectedCollection).toEqual('Test collection 1'); + expect(comp.selectedCollectionId).toEqual('collection1'); + }); + describe('moveCollection', () => { + it('should call itemDataService.moveToCollection', () => { + comp.itemId = 'item-id'; + comp.selectedCollectionId = 'selected-collection-id'; + comp.moveCollection(); + + expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', 'selected-collection-id'); + }); + it('should call notificationsService success message on success', () => { + // spyOn(notificationsServiceStub, 'success'); + + comp.moveCollection(); + + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + }); + }); + + describe('ItemMoveComponent fail', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemMoveComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataServiceFail}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + {provide: SearchService, useValue: mockSearchService}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemMoveComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should call notificationsService error message on fail', () => { + // spyOn(notificationsServiceStub, 'error'); comp.moveCollection(); - expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(notificationsServiceStub.error).toHaveBeenCalled(); }); }); }); - -describe('ItemMoveComponent fail', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], - declarations: [ItemMoveComponent], - providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: Router, useValue: routerStub}, - {provide: ItemDataService, useValue: mockItemDataServiceFail}, - {provide: NotificationsService, useValue: notificationsServiceStub}, - {provide: SearchService, useValue: mockSearchService}, - ], schemas: [ - CUSTOM_ELEMENTS_SCHEMA - ] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ItemMoveComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should call notificationsService error message on fail', () => { - spyOn(notificationsServiceStub, 'error'); - - comp.moveCollection(); - - expect(notificationsServiceStub.error).toHaveBeenCalled(); - }); -}); diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts index 9147ae2238..7eb0e4c10e 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts @@ -13,10 +13,10 @@ import {NotificationsService} from '../../../shared/notifications/notifications. import {TranslateService} from '@ngx-translate/core'; import {getSucceededRemoteData} from '../../../core/shared/operators'; import {ItemDataService} from '../../../core/data/item-data.service'; -import {RestResponse} from '../../../core/cache/response-cache.models'; import {getItemEditPath} from '../../item-page-routing.module'; import {Observable} from 'rxjs'; import {of as observableOf} from 'rxjs'; +import { RestResponse } from '../../../core/cache/response.models'; @Component({ selector: 'ds-item-move', diff --git a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts index 15feb5aeda..1901bf5fb4 100644 --- a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts @@ -4,11 +4,11 @@ import {ItemOperationComponent} from './item-operation.component'; import {TranslateModule} from '@ngx-translate/core'; import {By} from '@angular/platform-browser'; -const itemOperation: ItemOperation = new ItemOperation('key1', 'url1'); - -let fixture; -let comp; describe('ItemOperationComponent', () => { + let itemOperation: ItemOperation; + + let fixture; + let comp; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -18,6 +18,7 @@ describe('ItemOperationComponent', () => { })); beforeEach(() => { + itemOperation = new ItemOperation('key1', 'url1'); fixture = TestBed.createComponent(ItemOperationComponent); comp = fixture.componentInstance; diff --git a/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts b/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts index 0104dfbdb3..105889d42d 100644 --- a/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts +++ b/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts @@ -1,3 +1,7 @@ +/** + * Represents an item operation used on the edit item page with a key, an operation URL to which will be navigated + * when performing the action and an option to disable the operation. + */ export class ItemOperation { operationKey: string; 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 new file mode 100644 index 0000000000..651bebde58 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts @@ -0,0 +1,105 @@ +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; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemPrivateComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setDiscoverable: observableOf(new RestResponse(true, 200, 'OK')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + 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 }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); + + fixture = TestBed.createComponent(ItemPrivateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'private\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.private.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.private.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.private.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.private.cancel'); + }); + + describe('performAction', () => { + it('should call setDiscoverable function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setDiscoverable).toHaveBeenCalledWith(mockItem.id, false); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; 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 new file mode 100644 index 0000000000..d949e4fa6e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-private/item-private.component.ts @@ -0,0 +1,30 @@ +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({ + selector: 'ds-item-private', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the make item private page + */ +export class ItemPrivateComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'private'; + protected predicate = (rd: RemoteData) => !rd.payload.isDiscoverable; + + /** + * Perform the make private action to the item + */ + performAction() { + this.itemDataService.setDiscoverable(this.item.id, false).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} 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 new file mode 100644 index 0000000000..7516a84265 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts @@ -0,0 +1,105 @@ +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; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemPublicComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setDiscoverable: observableOf(new RestResponse(true, 200, 'OK')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + 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 }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); + + fixture = TestBed.createComponent(ItemPublicComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'public\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.public.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.public.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.public.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.public.cancel'); + }); + + describe('performAction', () => { + it('should call setDiscoverable function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setDiscoverable).toHaveBeenCalledWith(mockItem.id, true); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; 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 new file mode 100644 index 0000000000..272cf9a96f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-public/item-public.component.ts @@ -0,0 +1,30 @@ +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({ + selector: 'ds-item-public', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the make item public page + */ +export class ItemPublicComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'public'; + protected predicate = (rd: RemoteData) => rd.payload.isDiscoverable; + + /** + * Perform the make public action to the item + */ + performAction() { + this.itemDataService.setDiscoverable(this.item.id, true).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} 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 new file mode 100644 index 0000000000..f606fb4a83 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts @@ -0,0 +1,105 @@ +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; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemReinstateComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setWithDrawn: observableOf(new RestResponse(true, 200, 'OK')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + 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 }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); + + fixture = TestBed.createComponent(ItemReinstateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'reinstate\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.reinstate.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.reinstate.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.reinstate.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.reinstate.cancel'); + }); + + describe('performAction', () => { + it('should call setWithdrawn function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem.id, false); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; 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 new file mode 100644 index 0000000000..9c0e1c8d05 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts @@ -0,0 +1,30 @@ +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({ + selector: 'ds-item-reinstate', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the Item Reinstate page + */ +export class ItemReinstateComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'reinstate'; + protected predicate = (rd: RemoteData) => !rd.payload.isWithdrawn; + + /** + * Perform the reinstate action to the item + */ + performAction() { + this.itemDataService.setWithDrawn(this.item.id, false).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} 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 e92ae10b55..d293188aa6 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 {ItemOperation} from '../item-operation/itemOperation.model'; +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 @@ -37,51 +41,63 @@ export class ItemStatusComponent implements OnInit { * key: id value: url to action's component */ operations: ItemOperation[]; + /** * The keys of the actions (to loop over) */ actionsKeys; - constructor(private router: Router) { + 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 = []; + 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.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move')); }); - 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 = [ - new ItemOperation('mappedCollections',this.getCurrentUrl() + '/'), - new ItemOperation('move', this.getCurrentUrl() + '/move'), - ] } /** * 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 new file mode 100644 index 0000000000..ac49eee7e7 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts @@ -0,0 +1,105 @@ +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; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemWithdrawComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setWithDrawn: observableOf(new RestResponse(true, 200, 'OK')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + 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 }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); + + fixture = TestBed.createComponent(ItemWithdrawComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'withdraw\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.withdraw.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.withdraw.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.withdraw.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.withdraw.cancel'); + }); + + describe('performAction', () => { + it('should call setWithdrawn function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem.id, true); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; 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 new file mode 100644 index 0000000000..1fed1756a4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts @@ -0,0 +1,30 @@ +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({ + selector: 'ds-item-withdraw', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the Item Withdraw page + */ +export class ItemWithdrawComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'withdraw'; + protected predicate = (rd: RemoteData) => rd.payload.isWithdrawn; + + /** + * Perform the withdraw action to the item + */ + performAction() { + this.itemDataService.setWithDrawn(this.item.id, true).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} 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 new file mode 100644 index 0000000000..ce6e01df3d --- /dev/null +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + +
    {{'item.edit.modify.overview.field'| translate}}{{'item.edit.modify.overview.value'| translate}}{{'item.edit.modify.overview.language'| translate}}
    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 new file mode 100644 index 0000000000..07ad9a347c --- /dev/null +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts @@ -0,0 +1,59 @@ +import {Item} from '../../../core/shared/item.model'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {ModifyItemOverviewComponent} from './modify-item-overview.component'; +import {By} from '@angular/platform-browser'; +import {TranslateModule} from '@ngx-translate/core'; + +let comp: ModifyItemOverviewComponent; +let fixture: ComponentFixture; + +const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + metadata: { + 'dc.title': [ + { value: 'Mock item title', language: 'en' } + ], + 'dc.contributor.author': [ + { value: 'Mayer, Ed', language: '' } + ] + } +}); + +describe('ModifyItemOverviewComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ModifyItemOverviewComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ModifyItemOverviewComponent); + comp = fixture.componentInstance; + comp.item = mockItem; + + fixture.detectChanges(); + }); + it('should render a table of existing metadata fields in the item', () => { + + const metadataRows = fixture.debugElement.queryAll(By.css('tr.metadata-row')); + expect(metadataRows.length).toEqual(2); + + 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 new file mode 100644 index 0000000000..974bc8d37f --- /dev/null +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts @@ -0,0 +1,20 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {Item} from '../../../core/shared/item.model'; +import {MetadataMap} from '../../../core/shared/metadata.models'; + +@Component({ + selector: 'ds-modify-item-overview', + templateUrl: './modify-item-overview.component.html' +}) +/** + * Component responsible for rendering a table containing the metadatavalues from the to be edited item + */ +export class ModifyItemOverviewComponent implements OnInit { + + @Input() item: Item; + 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.html b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.html new file mode 100644 index 0000000000..fef76231c6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.html @@ -0,0 +1,16 @@ +
    +
    +
    +

    {{headerMessage | translate: {id: item.handle} }}

    +

    {{descriptionMessage | translate}}

    + + + + +
    +
    + +
    \ No newline at end of file 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 new file mode 100644 index 0000000000..32acdef467 --- /dev/null +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts @@ -0,0 +1,145 @@ +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'; + +/** + * Test component that implements the AbstractSimpleItemActionComponent used to test the + * AbstractSimpleItemActionComponent component + */ +@Component({ + selector: 'ds-simple-action', + templateUrl: './abstract-simple-item-action.component.html' +}) +export class MySimpleItemActionComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'myEditAction'; + protected predicate = (rd: RemoteData) => rd.payload.isWithdrawn; + + performAction() { + // do nothing + } + +} + +let comp: MySimpleItemActionComponent; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('AbstractSimpleItemActionComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj({ + findById: observableOf(new RemoteData(false, false, true, undefined, mockItem)) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + 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 }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + 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'); + + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.myEditAction.description'); + + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.myEditAction.confirm'); + + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.myEditAction.cancel'); + }); + + it('should perform action when the button is clicked', () => { + spyOn(comp, 'performAction'); + const performButton = fixture.debugElement.query(By.css('.perform-action')); + performButton.triggerEventHandler('click', null); + + expect(comp.performAction).toHaveBeenCalled(); + }); + + it('should process a RestResponse to navigate and display success notification', () => { + comp.processRestResponse(successfulRestResponse); + + expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]); + }); + + it('should process a RestResponse to navigate and display success notification', () => { + comp.processRestResponse(failRestResponse); + + expect(notificationsServiceStub.error).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]); + }); + +}); diff --git a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts new file mode 100644 index 0000000000..7773dbb573 --- /dev/null +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts @@ -0,0 +1,84 @@ +import {Component, OnInit, Predicate} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {TranslateService} from '@ngx-translate/core'; +import {Item} from '../../../core/shared/item.model'; +import {RemoteData} from '../../../core/data/remote-data'; +import {Observable} from 'rxjs'; +import {getSucceededRemoteData} from '../../../core/shared/operators'; +import {first, map} from 'rxjs/operators'; +import {findSuccessfulAccordingTo} from '../edit-item-operators'; +import {getItemEditPath} from '../../item-page-routing.module'; +import { RestResponse } from '../../../core/cache/response.models'; + +/** + * Component to render and handle simple item edit actions such as withdrawal and reinstatement. + * This component is not meant to be used itself but to be extended. + */ +@Component({ + selector: 'ds-simple-action', + templateUrl: './abstract-simple-item-action.component.html' +}) +export class AbstractSimpleItemActionComponent implements OnInit { + + itemRD$: Observable>; + item: Item; + + protected messageKey: string; + confirmMessage: string; + cancelMessage: string; + headerMessage: string; + descriptionMessage: string; + + protected predicate: Predicate>; + + constructor(protected route: ActivatedRoute, + protected router: Router, + protected notificationsService: NotificationsService, + protected itemDataService: ItemDataService, + protected translateService: TranslateService) { + } + + ngOnInit(): void { + this.itemRD$ = this.route.data.pipe( + map((data) => data.item), + getSucceededRemoteData() + )as Observable>; + + this.itemRD$.pipe(first()).subscribe((rd) => { + this.item = rd.payload; + } + ); + + this.confirmMessage = 'item.edit.' + this.messageKey + '.confirm'; + this.cancelMessage = 'item.edit.' + this.messageKey + '.cancel'; + this.headerMessage = 'item.edit.' + this.messageKey + '.header'; + this.descriptionMessage = 'item.edit.' + this.messageKey + '.description'; + } + + /** + * Perform the operation linked to this action + */ + performAction() { + // Overwrite in subclasses + }; + + /** + * Process the response obtained during the performAction method and navigate back to the edit page + * @param response from the action in the performAction method + */ + processRestResponse(response: RestResponse) { + if (response.isSuccessful) { + this.itemDataService.findById(this.item.id).pipe( + findSuccessfulAccordingTo(this.predicate)).subscribe(() => { + this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success')); + this.router.navigate([getItemEditPath(this.item.id)]); + }); + } else { + this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error')); + this.router.navigate([getItemEditPath(this.item.id)]); + } + } + +} 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-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.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 212dcddee8..67684d44af 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. @@ -18,7 +19,7 @@ export class MetadataUriValuesComponent extends MetadataValuesComponent { @Input() linktext: any; - @Input() values: any; + @Input() mdValues: MetadataValue[]; @Input() separator: 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.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts index 1c94b56d57..abcd90848d 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,7 +12,7 @@ import { Metadatum } from '../../../core/shared/metadatum.model'; }) export class MetadataValuesComponent { - @Input() values: Metadatum[]; + @Input() mdValues: MetadataValue[]; @Input() separator: 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.ts b/src/app/+item-page/full/full-item-page.component.ts index d09ac268ec..6e19a50864 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -6,7 +6,7 @@ import { ActivatedRoute } from '@angular/router'; import { Observable } 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'; @@ -34,7 +34,7 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit { itemRD$: Observable>; - metadata$: Observable; + metadata$: Observable; constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) { super(route, items, metadataService); diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index a155d00cc0..ec562842aa 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -1,12 +1,12 @@ -import {NgModule} from '@angular/core'; -import {RouterModule} from '@angular/router'; +import { NgModule } from '@angular/core'; +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 {URLCombiner} from '../core/url-combiner/url-combiner'; -import {getItemModulePath} from '../app-routing.module'; +import { ItemPageComponent } from './simple/item-page.component'; +import { FullItemPageComponent } from './full/full-item-page.component'; +import { ItemPageResolver } from './item-page.resolver'; +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..c60f9d3583 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { SharedModule } from './../shared/shared.module'; +import { SharedModule } from '../shared/shared.module'; import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageRoutingModule } from './item-page-routing.module'; 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 index 4a27848ec6..d6a569198c 100644 --- 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 @@ -1,3 +1,3 @@
    - +
    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..aac85d335f 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,3 @@

    - +

    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..a5561b22e5 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/+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/+logout-page/logout-page.component.html b/src/app/+logout-page/logout-page.component.html index 9c6185b665..b5012ed53b 100644 --- a/src/app/+logout-page/logout-page.component.html +++ b/src/app/+logout-page/logout-page.component.html @@ -1,6 +1,6 @@
    -
    +

    {{"logout.form.header" | translate}}

    diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts index 0683c74aed..46f14c042d 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 { MetadataMap } from '../core/shared/metadata.models'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; /** @@ -16,6 +16,6 @@ export class NormalizedSearchResult implements ListableObject { * The metadata that was used to find this item, hithighlighted */ @autoserialize - hitHighlights: Metadatum[]; + hitHighlights: MetadataMap; } 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..968bf9e420 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..f1dbedfb40 --- /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,121 @@ +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 = 'test name'; + const value1 = 'testvalue1'; + const value2 = 'test2'; + const value3 = 'another value3'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + const value: FacetValue = { + value: value2, + count: 20, + search: '' + }; + + const searchLink = '/search'; + const selectedValues = [value1]; + 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 wih 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 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..016ebf62a3 --- /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,103 @@ +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { map, take } 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'; + +@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; + + /** + * 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.filterValue.value); + } + + /** + * @returns {string} The base path to the search page + */ + getSearchLink() { + 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: string[]): void { + this.addQueryParams = { + [this.filterConfig.paramName]: [...selectedValues, this.filterValue.value], + 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-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..b485fe0fd0 --- /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.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-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..218730263b --- /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,125 @@ +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 = { + 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 = { + 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..67d31293b0 --- /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,106 @@ +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; + + /** + * 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 + */ + getSearchLink() { + 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..ba43bae100 --- /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}} + \ 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.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..545ba1d66b --- /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,95 @@ +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'; + +describe('SearchFacetSelectedOptionComponent', () => { + let comp: SearchFacetSelectedOptionComponent; + let fixture: ComponentFixture; + const filterName1 = 'test name'; + const value1 = 'testvalue1'; + const value2 = 'test2'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + + const searchLink = '/search'; + const selectedValues = [value1, value2]; + const selectedValues$ = observableOf(selectedValues); + 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 = value2; + 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 + }); + }); + }); +}); 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..23ad3eccba --- /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,88 @@ +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'; + +@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: string; + + /** + * The filter configuration for this facet option + */ + @Input() filterConfig: SearchFilterConfig; + + /** + * Emits the active values for this filter + */ + @Input() selectedValues$: Observable; + + /** + * 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 + */ + getSearchLink() { + 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: string[]): void { + this.removeQueryParams = { + [this.filterConfig.paramName]: selectedValues.filter((v) => v !== this.selectedValue), + 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-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..6369a7691e 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 @@ -3,6 +3,8 @@ 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 { 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,10 @@ export class SearchFacetFilterWrapperComponent implements OnInit { */ @Input() filterConfig: SearchFilterConfig; + /** + * 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,6 +36,7 @@ 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: [] } @@ -41,7 +48,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..cb3d4730b4 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 @@ -120,20 +120,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'); 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 4a171a3f3a..367947a377 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, first, map } from 'rxjs/operators'; +import { switchMap, distinctUntilChanged, map, take } 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'; @@ -21,6 +21,8 @@ import { SearchService } from '../../../search-service/search.service'; import { FILTER_CONFIG, 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'; @Component({ selector: 'ds-search-facet-filter', @@ -59,12 +61,12 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * 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; + selectedValues$: Observable; private collapseNextUpdate = true; /** @@ -72,6 +74,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ animationState = 'loading'; + /** + * Emits all current search options available in the search URL + */ + searchOptions$: Observable; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, @@ -86,10 +93,11 @@ 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.selectedValues$ = this.filterService.getSelectedValuesForFilter(this.filterConfig); + 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 } }), @@ -126,7 +134,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { this.animationState = 'ready'; this.filterValues$.next(rd); })); - this.subs.push(newValues$.pipe(first()).subscribe((rd) => { + this.subs.push(newValues$.pipe(take(1)).subscribe((rd) => { this.isLastPage$.next(hasNoValue(rd.payload.next)) })); })); @@ -189,7 +197,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * @param data The string from the input field */ onSubmit(data: any) { - this.selectedValues.pipe(first()).subscribe((selectedValues) => { + this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { if (isNotEmpty(data)) { this.router.navigate([this.getSearchLink()], { queryParams: @@ -203,6 +211,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 +226,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 +242,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ findSuggestions(data): void { if (isNotEmpty(data)) { - this.searchConfigService.searchOptions.pipe(first()).subscribe( + this.searchOptions$.pipe(take(1)).subscribe( (options) => { this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase()) .pipe( @@ -266,7 +250,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: facet.value + } }) } )) @@ -286,6 +273,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 f5dc5fff38..5c4db44d24 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}}
    -
    +
    +
    {{'search.filters.filter.' + filter.name + '.head'| translate}}
    +
    \ 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 6e49172a48..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,12 +1,13 @@ @import '../../../../styles/variables.scss'; @import '../../../../styles/mixins.scss'; -:host { - border: 1px solid map-get($theme-colors, light); - .search-filter-wrapper.closed { - overflow: hidden; - } - .filter-toggle { - line-height: $line-height-base; - } -} \ No newline at end of file +:host .facet-filter { + border: 1px solid map-get($theme-colors, light); + cursor: pointer; + .search-filter-wrapper.closed { + overflow: hidden; + } + .filter-toggle { + line-height: $line-height-base; + } +} 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..30ef349675 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,7 @@ 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'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -33,9 +34,7 @@ describe('SearchFilterComponent', () => { }, expand: (filter) => { }, - initialCollapse: (filter) => { - }, - initialExpand: (filter) => { + initializeFilter: (filter) => { }, getSelectedValuesForFilter: (filter) => { return observableOf([filterName1, filterName2, filterName3]) @@ -55,6 +54,8 @@ describe('SearchFilterComponent', () => { getFacetValuesFor: (filter) => mockResults }; + const searchConfigServiceStub = {}; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], @@ -65,6 +66,7 @@ describe('SearchFilterComponent', () => { provide: SearchFilterService, useValue: mockFilterService }, + { provide: SearchConfigurationService, useValue: searchConfigServiceStub }, ], 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 87f8edc1ea..14ba8f0b76 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,11 +1,12 @@ - -import {first} from 'rxjs/operators'; +import { filter, first, map, startWith, switchMap, take } from 'rxjs/operators'; import { Component, Input, OnInit } from '@angular/core'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterService } from './search-filter.service'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable, of as observableOf } 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'; @Component({ selector: 'ds-search-filter', @@ -26,9 +27,24 @@ export class SearchFilterComponent implements OnInit { /** * 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, private searchConfigService: SearchConfigurationService) { } /** @@ -37,11 +53,13 @@ export class SearchFilterComponent implements OnInit { * Else, the filter should initially be collapsed */ ngOnInit() { - this.getSelectedValues().pipe(first()).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); } }); } @@ -57,30 +75,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); } @@ -90,7 +99,7 @@ export class SearchFilterComponent implements OnInit { */ finishSlide(event: any): void { if (event.fromState === 'collapsed') { - this.collapsed = false; + this.closed = false; } } @@ -100,7 +109,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..187bcd50d0 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,9 @@ -import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions'; -import { isEmpty } from '../../../shared/empty.util'; +import { + SearchFilterAction, + SearchFilterActionTypes, + SearchFilterInitializeAction +} from './search-filter.actions'; +import { isEmpty, isNotUndefined } from '../../../shared/empty.util'; /** * Interface that represents the state for a single filters @@ -28,27 +32,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..19239d899c 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,8 +5,7 @@ import { SearchFilterDecrementPageAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, + SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; @@ -62,23 +61,13 @@ describe('SearchFilterService', () => { service = new SearchFilterService(store, routeServiceStub); }); - 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)); }); }); 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..bed4b1777f 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 { Injectable, InjectionToken } from '@angular/core'; -import { map } from 'rxjs/operators'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { @@ -8,8 +8,7 @@ import { SearchFilterDecrementPageAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, + SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; @@ -17,7 +16,8 @@ import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { RouteService } from '../../../shared/services/route.service'; import { Params } from '@angular/router'; - +import { SearchOptions } from '../../search-options.model'; +// const spy = create(); const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); @@ -60,7 +60,7 @@ 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( @@ -88,13 +88,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 +107,8 @@ export class SearchFilterService { } else { return 1; } - })); + }), + distinctUntilChanged()); } /** @@ -134,19 +136,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-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..b6ae0ada63 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..9d35cc518a 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..930ea8c9fb 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 @@ -106,16 +106,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' }; 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..ebdb797500 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'; @@ -23,16 +18,26 @@ import { RouteService } from '../../../../shared/services/route.service'; import { hasValue } from '../../../../shared/empty.util'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +/** + * 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'], @@ -85,8 +90,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,23 +101,6 @@ 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 */ @@ -122,8 +110,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple this.router.navigate([this.getSearchLink()], { 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 +136,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..25ff8e46d3 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-filter/search-text-filter/search-text-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts index bb396a6692..fd14d6d3de 100644 --- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts @@ -1,6 +1,4 @@ -import { animate, state, style, transition, trigger } from '@angular/animations'; -import { Component, HostBinding, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; import { FilterType } from '../../../search-service/filter-type.model'; import { facetLoad, 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..895765f6ac 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 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 7f1eb513ea..1dd747e908 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -1,12 +1,11 @@ -import { Observable, of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs'; -import { filter, map, mergeMap, startWith, switchMap } from 'rxjs/operators'; +import { map } 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'; @@ -53,27 +52,9 @@ export class SearchFiltersComponent { } /** - * 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 { - // console.log(filter.name); - 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-page.component.html b/src/app/+search-page/search-page.component.html index 653f5e8cd4..6476f8bd68 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -26,7 +26,7 @@
    diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 816e3d67bf..0c572a3a84 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -62,7 +62,6 @@ export class SearchPageComponent implements OnInit { constructor(private service: SearchService, private sidebarService: SearchSidebarService, private windowService: HostWindowService, - private filterService: SearchFilterService, private searchConfigService: SearchConfigurationService) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index a231d8da5a..dc895e5d70 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -1,45 +1,40 @@ -import {ModuleWithProviders, NgModule} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {CoreModule} from '../core/core.module'; -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'; -import {SearchSettingsComponent} from './search-settings/search-settings.component'; -import {EffectsModule} from '@ngrx/effects'; -import {SearchFiltersComponent} from './search-filters/search-filters.component'; -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 {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'; -import {SearchFacetFilterWrapperComponent} from './search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component'; -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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CoreModule } from '../core/core.module'; +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 { SearchSidebarComponent } from './search-sidebar/search-sidebar.component'; +import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; +import { SearchSidebarEffects } from './search-sidebar/search-sidebar.effects'; +import { SearchSettingsComponent } from './search-settings/search-settings.component'; +import { EffectsModule } from '@ngrx/effects'; +import { SearchFiltersComponent } from './search-filters/search-filters.component'; +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 { 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'; +import { SearchFacetFilterWrapperComponent } from './search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component'; +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'; const effects = [ SearchSidebarEffects ]; -const PROVIDERS = [ - SearchService, - SearchSidebarService, - SearchFilterService, - SearchConfigurationService -]; - @NgModule({ imports: [ SearchPageRoutingModule, @@ -53,13 +48,9 @@ const PROVIDERS = [ SearchResultsComponent, SearchSidebarComponent, SearchSettingsComponent, - ItemSearchResultListElementComponent, - CollectionSearchResultListElementComponent, - CommunitySearchResultListElementComponent, ItemSearchResultGridElementComponent, CollectionSearchResultGridElementComponent, CommunitySearchResultGridElementComponent, - CommunitySearchResultListElementComponent, SearchFiltersComponent, SearchFilterComponent, SearchFacetFilterComponent, @@ -70,9 +61,14 @@ const PROVIDERS = [ SearchTextFilterComponent, SearchHierarchyFilterComponent, SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent ], providers: [ - ...PROVIDERS + SearchSidebarService, + SearchFilterService, + SearchConfigurationService ], entryComponents: [ ItemSearchResultListElementComponent, @@ -86,6 +82,9 @@ const PROVIDERS = [ SearchTextFilterComponent, SearchHierarchyFilterComponent, SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent ] }) @@ -93,12 +92,4 @@ const PROVIDERS = [ * This module handles all components and pipes that are necessary for the search page */ export class SearchPageModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: CoreModule, - providers: [ - ...PROVIDERS - ] - }; - } } diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts index 00b1c62a99..ff865610c6 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'; /** @@ -14,6 +14,6 @@ export class SearchResult implements ListableObject { /** * 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.spec.ts b/src/app/+search-page/search-results/search-results.component.spec.ts index 54463d916d..8d0566d1df 100644 --- a/src/app/+search-page/search-results/search-results.component.spec.ts +++ b/src/app/+search-page/search-results/search-results.component.spec.ts @@ -111,34 +111,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 +165,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-service/facet-value.model.ts b/src/app/+search-page/search-service/facet-value.model.ts index a597528d50..0f673f3485 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'; /** 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..f1f4ef8bdc 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 @@ -117,7 +117,7 @@ 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(); @@ -131,7 +131,7 @@ 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(); 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..7ba1ebd75f 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -186,7 +186,7 @@ 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.getScopePart(defaults.scope), this.getQueryPart(defaults.query), @@ -204,7 +204,7 @@ 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), 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 6bfc9200ec..ca48b02aa7 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -8,21 +8,18 @@ import { SearchService } from './search.service'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { RequestService } from '../../core/data/request.service'; -import { ResponseCacheService } from '../../core/cache/response-cache.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 { PaginatedSearchOptions } from '../paginated-search-options.model'; import { RemoteData } from '../../core/data/remote-data'; -import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; import { RequestEntry } from '../../core/data/request.reducer'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { FacetConfigSuccessResponse, SearchSuccessResponse -} from '../../core/cache/response-cache.models'; +} 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'; @@ -54,7 +51,6 @@ describe('SearchService', () => { providers: [ { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, - { provide: ResponseCacheService, useValue: getMockResponseCacheService() }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, @@ -86,11 +82,10 @@ describe('SearchService', () => { }; const remoteDataBuildService = { - toRemoteDataObservable: (requestEntryObs: Observable, responseCacheObs: Observable, payloadObs: Observable) => { - return observableCombineLatest(requestEntryObs, - responseCacheObs, payloadObs).pipe( - map(([req, res, pay]) => { - return { req, res, pay }; + toRemoteDataObservable: (requestEntryObs: Observable, payloadObs: Observable) => { + return observableCombineLatest(requestEntryObs, payloadObs).pipe( + map(([req, pay]) => { + return { req, pay }; }) ); }, @@ -113,7 +108,6 @@ describe('SearchService', () => { providers: [ { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, - { provide: ResponseCacheService, useValue: getMockResponseCacheService() }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: HALEndpointService, useValue: halService }, @@ -161,11 +155,9 @@ 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 responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const response = new SearchSuccessResponse(queryResponse, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); - (searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ searchService.search(searchOptions).subscribe((t) => { }); // subscribe to make sure all methods are called @@ -183,19 +175,14 @@ describe('SearchService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint); }); - it('should call get on the request service with the correct request url', () => { - expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint); - }); }); 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 responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const response = new FacetConfigSuccessResponse(filterConfig, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); - (searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ searchService.getConfig(null).subscribe((t) => { }); // subscribe to make sure all methods are called @@ -213,9 +200,6 @@ describe('SearchService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint); }); - it('should call get on the request service with the correct request url', () => { - expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint); - }); }); describe('when getConfig is called with a scope', () => { @@ -223,11 +207,9 @@ describe('SearchService', () => { const scope = 'test'; const requestUrl = endPoint + '?scope=' + scope; const filterConfig = [new SearchFilterConfig()]; - const response = new FacetConfigSuccessResponse(filterConfig, '200'); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const response = new FacetConfigSuccessResponse(filterConfig, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); - (searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ searchService.getConfig(scope).subscribe((t) => { }); // subscribe to make sure all methods are called @@ -245,9 +227,6 @@ describe('SearchService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(requestUrl); }); - it('should call get on the request service with the correct request url', () => { - expect((searchService as any).responseCache.get).toHaveBeenCalledWith(requestUrl); - }); }); }); }); diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 1503440eb0..2c0f1f4e55 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,4 +1,4 @@ -import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; import { ActivatedRoute, @@ -7,15 +7,13 @@ import { Router, UrlSegmentGroup } from '@angular/router'; -import { flatMap, map, switchMap } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { FacetConfigSuccessResponse, FacetValueSuccessResponse, SearchSuccessResponse -} from '../../core/cache/response-cache.models'; -import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; -import { ResponseCacheService } from '../../core/cache/response-cache.service'; +} from '../../core/cache/response.models'; import { PaginatedList } from '../../core/data/paginated-list'; import { ResponseParsingService } from '../../core/data/parsing.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -24,7 +22,11 @@ import { RequestService } from '../../core/data/request.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { configureRequest, getSucceededRemoteData } from '../../core/shared/operators'; +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 { NormalizedSearchResult } from '../normalized-search-result.model'; @@ -68,7 +70,6 @@ export class SearchService implements OnDestroy { constructor(private router: Router, private route: ActivatedRoute, - protected responseCache: ResponseCacheService, protected requestService: RequestService, private rdb: RemoteDataBuildService, private halService: HALEndpointService, @@ -98,16 +99,12 @@ export class SearchService implements OnDestroy { configureRequest(this.requestService) ); const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); // get search results from response cache - const sqrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const sqrObs: Observable = requestEntryObs.pipe( + filterSuccessfulResponses(), map((response: SearchSuccessResponse) => response.results) ); @@ -115,10 +112,11 @@ 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) => - this.rdb.buildSingle(nsr.dspaceObject)); + return sqr.objects.map((nsr: NormalizedSearchResult) => { + return this.rdb.buildSingle(nsr.dspaceObject); + }) }), - flatMap((input: Array>>) => this.rdb.aggregate(input)) + switchMap((input: Array>>) => this.rdb.aggregate(input)), ); // Create search results again with the correct dso objects linked to each result @@ -139,8 +137,8 @@ export class SearchService implements OnDestroy { }) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetValueSuccessResponse) => response.pageInfo) ); @@ -150,7 +148,7 @@ export class SearchService implements OnDestroy { }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } /** @@ -182,21 +180,17 @@ export class SearchService implements OnDestroy { ); const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); // get search results from response cache - const facetConfigObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const facetConfigObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetConfigSuccessResponse) => response.results.map((result: any) => Object.assign(new SearchFilterConfig(), result))) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, facetConfigObs); } /** @@ -229,21 +223,17 @@ export class SearchService implements OnDestroy { ); const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); // get search results from response cache - const facetValueObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const facetValueObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetValueSuccessResponse) => response.results) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetValueSuccessResponse) => response.pageInfo) ); @@ -253,7 +243,7 @@ export class SearchService implements OnDestroy { }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } /** 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 71959b558b..5ff1e3c8fa 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.html +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.html @@ -4,7 +4,7 @@
    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..be956ee895 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -2,24 +2,36 @@ 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: '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.html b/src/app/app.component.html index 95db61ab5a..898208db80 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,20 +1,22 @@
    -
    - + +
    + - - + + -
    -
    - -
    - -
    +
    +
    + +
    + +
    - -
    + +
    - - diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e4c51ae37b..fa7e7a873a 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -1,7 +1,7 @@ @import '../styles/variables.scss'; +@import '../styles/helpers/font_awesome_imports.scss'; @import '../../node_modules/bootstrap/scss/bootstrap.scss'; -@import '../../node_modules/nouislider/distribute/nouislider.min.css'; -@import "../../node_modules/font-awesome/scss/font-awesome.scss"; +@import '../../node_modules/nouislider/distribute/nouislider.min'; html { position: relative; @@ -11,8 +11,8 @@ html { body { overflow-x: hidden; } -// Sticky Footer +// Sticky Footer .outer-wrapper { display: flex; margin: 0; @@ -25,10 +25,27 @@ body { min-height: 100vh; flex-direction: column; width: 100%; + position: relative; } .main-content { - flex: 1 0 auto; + z-index: $main-z-index; + 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; +} + +ds-admin-sidebar { + position: fixed; + z-index: $sidebar-z-index; +} + diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index c88b999786..bd2d832c67 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -34,12 +34,22 @@ 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; let de: DebugElement; let el: HTMLElement; +const menuService = new MenuServiceStub(); describe('App component', () => { @@ -63,8 +73,13 @@ describe('App component', () => { { provide: MetadataService, useValue: new MockMetadataService() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, { provide: AuthService, useValue: new AuthServiceMock() }, - { provide: Router, useValue: {} }, - AppComponent + { 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, + RouteService ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) @@ -75,7 +90,6 @@ describe('App component', () => { fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; // component test instance - // query for the
    by CSS element selector de = fixture.debugElement.query(By.css('div.outer-wrapper')); el = de.nativeElement; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7d4bfe4f33..da01b1297a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { filter, first, take } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -23,16 +23,30 @@ 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'; +import { MenuID } from './shared/menu/initial-menus-state'; +import { Observable } from 'rxjs/internal/Observable'; +import { slideSidebarPadding } from './shared/animations/slide'; +import { combineLatest as combineLatestObservable } from 'rxjs'; +import { HostWindowService } from './shared/host-window.service'; @Component({ selector: 'ds-app', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None + encapsulation: ViewEncapsulation.None, + animations: [slideSidebarPadding] }) export class AppComponent implements OnInit, AfterViewInit { isLoading = true; + sidebarVisible: Observable; + slideSidebarOver: Observable; + collapsedSidebarWidth: Observable; + totalSidebarWidth: Observable; constructor( @Inject(GLOBAL_CONFIG) public config: GlobalConfig, @@ -42,18 +56,34 @@ export class AppComponent implements OnInit, AfterViewInit { private metadata: MetadataService, private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, private authService: AuthService, - private router: Router + 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); } + this.storeCSSVariables(); + } ngOnInit() { @@ -64,10 +94,26 @@ export class AppComponent implements OnInit, AfterViewInit { // Whether is not authenticathed try to retrieve a possible stored auth token this.store.pipe(select(isAuthenticated), - first(), + take(1), filter((authenticated) => !authenticated) ).subscribe((authenticated) => this.authService.checkAuthenticationToken()); + this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); + this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); + this.totalSidebarWidth = this.cssService.getVariable('totalSidebarWidth'); + + const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN); + this.slideSidebarOver = combineLatestObservable(sidebarCollapsed, this.windowService.isXsOrSm()) + .pipe( + map(([collapsed, mobile]) => collapsed || mobile) + ); + } + + private storeCSSVariables() { + const vars = variables.locals || {}; + Object.keys(vars).forEach((name: string) => { + this.cssService.addCSSVariable(name, vars[name]); + }) } ngAfterViewInit() { diff --git a/src/app/app.effects.ts b/src/app/app.effects.ts index 6a53d7b619..b4ddb24c8e 100644 --- a/src/app/app.effects.ts +++ b/src/app/app.effects.ts @@ -1,10 +1,9 @@ - -import { HeaderEffects } from './header/header.effects'; import { StoreEffects } from './store.effects'; import { NotificationsEffects } from './shared/notifications/notifications.effects'; +import { NavbarEffects } from './navbar/navbar.effects'; export const appEffects = [ StoreEffects, - HeaderEffects, - NotificationsEffects + NavbarEffects, + NotificationsEffects, ]; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9618dfaca3..f9d6e50dcc 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,12 @@ 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'; +import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component'; +import { NavbarModule } from './navbar/navbar.module'; export function getConfig() { return ENV_CONFIG; @@ -48,9 +54,11 @@ export function getMetaReducers(config: GlobalConfig): Array = { router: fromRouter.routerReducer, + history: historyReducer, hostWindow: hostWindowReducer, - header: headerReducer, forms: formReducer, + metadataRegistry: metadataRegistryReducer, notifications: notificationsReducer, searchSidebar: sidebarReducer, searchFilter: filterReducer, - truncatable: truncatableReducer + truncatable: truncatableReducer, + cssVariables: cssVariablesReducer, + menus: menusReducer, }; export const routerStateSelector = (state: AppState) => state.router; + +export function keySelector(key: string, selector): MemoizedSelector { + return createSelector(selector, (state) => { + if (hasValue(state)) { + return state[key]; + } else { + return undefined; + } + }); +} diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts index b6df1fac34..e37475d94c 100644 --- a/src/app/core/auth/auth-object-factory.ts +++ b/src/app/core/auth/auth-object-factory.ts @@ -3,10 +3,10 @@ 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'; export class AuthObjectFactory { - public static getConstructor(type): GenericConstructor { + public static getConstructor(type): GenericConstructor> { switch (type) { case AuthType.EPerson: { return NormalizedEPerson diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 7cb5fae7e4..6d782cbbe2 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -2,15 +2,15 @@ import { Observable, of as observableOf, throwError as observableThrowError } fr import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; 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 { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { AuthStatusResponse, ErrorResponse } from '../cache/response-cache.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() export class AuthRequestService { @@ -19,18 +19,15 @@ export class AuthRequestService { constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected halService: HALEndpointService, - protected responseCache: ResponseCacheService, protected requestService: RequestService) { } protected fetchRequest(request: RestRequest): Observable { - return this.responseCache.get(request.href).pipe( - map((entry: ResponseCacheEntry) => entry.response), - // TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed - tap(() => this.responseCache.remove(request.href)), + return this.requestService.getByUUID(request.uuid).pipe( + getResponseFromEntry(), mergeMap((response) => { if (response.isSuccessful && isNotEmpty(response)) { - return observableOf((response as AuthStatusResponse).response); + return observableOf((response as AuthStatusResponse).response); } else if (!response.isSuccessful) { return observableThrowError(new Error((response as ErrorResponse).errorMessage)); } 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 138d0f1be3..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,21 +1,36 @@ -import { AuthStatusResponse } from '../cache/response-cache.models'; +import { async, TestBed } from '@angular/core/testing'; + +import { Store, StoreModule } from '@ngrx/store'; -import { ObjectCacheService } from '../cache/object-cache.service'; 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 GlobalConfig; - 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); }); @@ -39,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 = { @@ -61,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, @@ -100,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 8efa36f9e2..3cb00789f6 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core'; import { AuthObjectFactory } from './auth-object-factory'; import { BaseResponseParsingService } from '../data/base-response-parsing.service'; -import { AuthStatusResponse, RestResponse } from '../cache/response-cache.models'; +import { AuthStatusResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; @@ -26,11 +26,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.href); - return new AuthStatusResponse(response, data.statusCode); + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 200)) { + const response = this.process(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 AuthStatus, data.statusCode, data.statusText); } } 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.effects.ts b/src/app/core/auth/auth.effects.ts index c57fa3f70e..1e68802af8 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,6 +1,6 @@ import { of as observableOf, Observable } from 'rxjs'; -import { filter, debounceTime, switchMap, take, tap, catchError, map, first } from 'rxjs/operators'; +import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; // import @ngrx @@ -47,7 +47,7 @@ export class AuthEffects { ofType(AuthActionTypes.AUTHENTICATE), switchMap((action: AuthenticateAction) => { return this.authService.authenticate(action.payload.email, action.payload.password).pipe( - first(), + take(1), map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), catchError((error) => observableOf(new AuthenticationErrorAction(error))) ); @@ -127,7 +127,7 @@ export class AuthEffects { switchMap(() => { return this.store.pipe( select(isAuthenticated), - first(), + take(1), filter((authenticated) => !authenticated), tap(() => this.authService.removeToken()), tap(() => this.authService.resetAuthenticationError()) diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 89c9ed1951..72b0cc2616 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -9,10 +9,10 @@ import { of as observableOf } from 'rxjs'; import { AuthInterceptor } from './auth.interceptor'; import { AuthService } from './auth.service'; import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RestRequestMethod } from '../data/request.models'; import { RouterStub } from '../../shared/testing/router-stub'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +import { RestRequestMethod } from '../data/rest-request-method'; describe(`AuthInterceptor`, () => { let service: DSpaceRESTv2Service; @@ -49,7 +49,7 @@ describe(`AuthInterceptor`, () => { describe('when has a valid token', () => { it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint', () => { - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => { + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => { expect(response).toBeTruthy(); }); @@ -60,7 +60,7 @@ describe(`AuthInterceptor`, () => { }); it('should add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint', () => { - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => { + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => { expect(response).toBeTruthy(); }); @@ -85,11 +85,11 @@ describe(`AuthInterceptor`, () => { it('should redirect to login', () => { - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { expect(response).toBeTruthy(); }); - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems'); }); 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..c461148eea 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -73,7 +73,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 diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 67f533d4ad..fdb372f643 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Observable, of as observableOf } from 'rxjs'; +import {Observable, of, of as observableOf} from 'rxjs'; import { distinctUntilChanged, filter, @@ -152,7 +152,7 @@ export class AuthService { // 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()); + const person$ = this.rdbService.buildSingle(status.eperson.toString()); return person$.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); @@ -277,7 +277,7 @@ export class AuthService { public isTokenExpiring(): Observable { return this.store.pipe( select(isTokenRefreshing), - first(), + take(1), map((isRefreshing: boolean) => { if (this.isTokenExpired() || isRefreshing) { return false; @@ -360,7 +360,7 @@ export class AuthService { */ public redirectToPreviousUrl() { this.getRedirectUrl().pipe( - first()) + take(1)) .subscribe((redirectUrl) => { if (isNotEmpty(redirectUrl)) { 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/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/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 089cbd0ba2..b61b11a4f2 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,4 +1,4 @@ -import { first, map, switchMap } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @@ -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. @@ -40,13 +39,14 @@ export class ServerAuthService extends AuthService { 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()); - // person$.subscribe(() => console.log('test')); - return person$.pipe(map((eperson) => eperson.payload)); + const person$ = this.rdbService.buildSingle(status.eperson.toString()); + return person$.pipe( + map((eperson) => eperson.payload) + ); } else { throw(new Error('Not authenticated')); } - })) + })); } /** @@ -61,7 +61,7 @@ export class ServerAuthService extends AuthService { */ public redirectToPreviousUrl() { this.getRedirectUrl().pipe( - first()) + take(1)) .subscribe((redirectUrl) => { if (isNotEmpty(redirectUrl)) { // override the route reuse strategy 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 d43a26ed4b..725b371c14 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -2,19 +2,19 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from '../data/request.models'; 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'; describe('BrowseService', () => { let scheduler: TestScheduler; let service: BrowseService; - let responseCache: ResponseCacheService; let requestService: RequestService; let rdbService: RemoteDataBuildService; @@ -79,22 +79,14 @@ describe('BrowseService', () => { }) ]; - function initMockResponseCacheService(isSuccessful: boolean) { - const rcs = getMockResponseCacheService(); - (rcs.get as any).and.returnValue(cold('b-', { - b: { - response: { - isSuccessful, - payload: browseDefinitions, - } - } - })); - return rcs; - } + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: browseDefinitions } as any + } as RequestEntry) + }; function initTestService() { return new BrowseService( - responseCache, requestService, halService, rdbService @@ -108,8 +100,7 @@ describe('BrowseService', () => { describe('getBrowseDefinitions', () => { beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(halService, 'getEndpoint').and @@ -123,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', () => { @@ -147,8 +138,7 @@ describe('BrowseService', () => { const mockAuthorName = 'Donald Smith'; beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and @@ -162,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(); @@ -181,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(); @@ -200,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); }); }); @@ -212,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); }); }); }); @@ -221,8 +211,7 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions fires', () => { beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and @@ -231,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); }); @@ -277,20 +266,53 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions doesn\'t fire', () => { it('should return undefined', () => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); 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 ddce277e7e..bf368e37ce 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,26 +1,20 @@ 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-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest, - GetRequest, RestRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; @@ -29,21 +23,26 @@ import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, - filterSuccessfulResponses, getBrowseDefinitionLinks, + filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence, getRemoteDataPayload, - getRequestFromSelflink, - getResponseFromSelflink + 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++) { @@ -51,18 +50,20 @@ export class BrowseService { const nextPart = [...prevParts, '*'].join('.'); searchFor.push(nextPart); } - searchFor.push(metadatumKey); + searchFor.push(metadataKey); return searchFor; } constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService, private rdb: RemoteDataBuildService, ) { } + /** + * Get all BrowseDefinitions + */ getBrowseDefinitions(): Observable> { const request$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), @@ -72,11 +73,9 @@ export class BrowseService { ); const href$ = request$.pipe(map((request: RestRequest) => request.href)); - const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - const payload$ = responseCache$.pipe( + const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); + const payload$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse) => response.payload), ensureArrayHasValue(), map((definitions: BrowseDefinition[]) => definitions @@ -84,21 +83,25 @@ export class BrowseService { distinctUntilChanged() ); - return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + 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}`); } @@ -106,53 +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(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - - const payload$ = responseCache$.pipe( - filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), - 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$, responseCache$, 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}`); } @@ -160,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}`); } @@ -168,30 +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(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - - const payload$ = responseCache$.pipe( - filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), - 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$, responseCache$, 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 @@ -202,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]; } @@ -213,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 8aea54102c..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); @@ -32,7 +36,7 @@ describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; beforeEach(() => { - service = new RemoteDataBuildService(undefined, undefined, undefined); + service = new RemoteDataBuildService(undefined, undefined); }); describe('when toPaginatedList is called', () => { 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 aa622b20c5..c0b359e7ea 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,69 +1,58 @@ -import { - combineLatest as observableCombineLatest, - Observable, - of as observableOf, - race as observableRace -} from 'rxjs'; import { Injectable } from '@angular/core'; -import { distinctUntilChanged, flatMap, map, startWith } 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 } 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-cache.models'; -import { ResponseCacheEntry } from '../response-cache.reducer'; -import { ResponseCacheService } from '../response-cache.service'; +import { DSOSuccessResponse, ErrorResponse } from '../response.models'; import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; import { PageInfo } from '../../shared/page-info.model'; import { filterSuccessfulResponses, - getRequestFromSelflink, - getResourceLinksFromResponse, - getResponseFromSelflink + getRequestFromRequestHref, + getRequestFromRequestUUID, + getResourceLinksFromResponse } from '../../shared/operators'; +import { CacheableObject } from '../object-cache.reducer'; @Injectable() export class RemoteDataBuildService { constructor(protected objectCache: ObjectCacheService, - protected responseCache: ResponseCacheService, protected requestService: RequestService) { } - buildSingle(href$: string | Observable): Observable> { + buildSingle(href$: string | Observable): Observable> { if (typeof href$ === 'string') { href$ = observableOf(href$); } - const requestHref$ = href$.pipe(flatMap((href: string) => - this.objectCache.getRequestHrefBySelfLink(href))); + const requestUUID$ = href$.pipe( + switchMap((href: string) => + this.objectCache.getRequestUUIDBySelfLink(href)), + ); const requestEntry$ = observableRace( - href$.pipe(getRequestFromSelflink(this.requestService)), - requestHref$.pipe(getRequestFromSelflink(this.requestService)) + href$.pipe(getRequestFromRequestHref(this.requestService)), + requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)), ); - - const responseCache$ = observableRace( - href$.pipe(getResponseFromSelflink(this.responseCache)), - requestHref$.pipe(getResponseFromSelflink(this.responseCache)) - ); - // always use self link if that is cached, only if it isn't, get it via the response. const payload$ = observableCombineLatest( href$.pipe( - flatMap((href: string) => this.objectCache.getBySelfLink(href)), - startWith(undefined) - ), - responseCache$.pipe( + switchMap((href: string) => this.objectCache.getObjectBySelfLink(href)), + startWith(undefined)), + requestEntry$.pipe( getResourceLinksFromResponse(), - flatMap((resourceSelfLinks: string[]) => { + switchMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { - return this.objectCache.getBySelfLink(resourceSelfLinks[0]); + return this.objectCache.getObjectBySelfLink(resourceSelfLinks[0]); } else { return observableOf(undefined); } @@ -80,27 +69,31 @@ export class RemoteDataBuildService { } }), hasValueOperator(), - map((normalized: TNormalized) => { - return this.build(normalized); + map((normalized: NormalizedObject) => { + return this.build(normalized); }), startWith(undefined), distinctUntilChanged() ); - return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$); } - toRemoteDataObservable(requestEntry$: Observable, responseCache$: Observable, payload$: Observable) { - return observableCombineLatest(requestEntry$, responseCache$.pipe(startWith(undefined)), payload$).pipe( - map(([reqEntry, resEntry, payload]) => { + toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { + return observableCombineLatest(requestEntry$, payload$).pipe( + map(([reqEntry, payload]) => { const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; let isSuccessful: boolean; let error: RemoteDataError; - if (hasValue(resEntry) && hasValue(resEntry.response)) { - isSuccessful = resEntry.response.isSuccessful; - const errorMessage = isSuccessful === false ? (resEntry.response as ErrorResponse).errorMessage : undefined; + if (hasValue(reqEntry) && hasValue(reqEntry.response)) { + isSuccessful = reqEntry.response.isSuccessful; + const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; if (hasValue(errorMessage)) { - error = new RemoteDataError(resEntry.response.statusCode, errorMessage); + error = new RemoteDataError( + (reqEntry.response as ErrorResponse).statusCode, + (reqEntry.response as ErrorResponse).statusText, + errorMessage + ); } } return new RemoteData( @@ -114,33 +107,30 @@ export class RemoteDataBuildService { ); } - buildList(href$: string | Observable): Observable>> { + buildList(href$: string | Observable): Observable>> { if (typeof href$ === 'string') { href$ = observableOf(href$); } - const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - - const tDomainList$ = responseCache$.pipe( + const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); + const tDomainList$ = requestEntry$.pipe( 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); }); })); }), startWith([]), - distinctUntilChanged() + distinctUntilChanged(), ); - - const pageInfo$ = responseCache$.pipe( + const pageInfo$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => { - if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) { - const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo; + map((response: DSOSuccessResponse) => { + if (hasValue((response as DSOSuccessResponse).pageInfo)) { + const resPageInfo = (response as DSOSuccessResponse).pageInfo; if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); } else { @@ -156,12 +146,11 @@ export class RemoteDataBuildService { }) ); - return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$); } - build(normalized: TNormalized): TDomain { + build(normalized: NormalizedObject): T { const links: any = {}; - const relationships = getRelationships(normalized.constructor) || []; relationships.forEach((relationship: string) => { @@ -204,7 +193,6 @@ export class RemoteDataBuildService { } } }); - const domainModel = getMapsTo(normalized.constructor); return Object.assign(new domainModel(), normalized, links); } @@ -238,16 +226,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); @@ -266,8 +263,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/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..9e8c034e81 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,21 +45,21 @@ 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[]; 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..53d7f475fc 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -6,13 +6,21 @@ 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 { 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,6 +40,9 @@ export class NormalizedObjectFactory { case ResourceType.BitstreamFormat: { return NormalizedBitstreamFormat } + case ResourceType.License: { + return NormalizedLicense + } case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } @@ -41,6 +52,30 @@ export class NormalizedObjectFactory { 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.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.actions.ts b/src/app/core/cache/object-cache.actions.ts index a136b04248..8531677ffc 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -2,6 +2,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; import { CacheableObject } from './object-cache.reducer'; +import { Operation } from 'fast-json-patch'; /** * The list of ObjectCacheAction type definitions @@ -9,7 +10,9 @@ import { CacheableObject } from './object-cache.reducer'; export const ObjectCacheActionTypes = { ADD: type('dspace/core/cache/object/ADD'), REMOVE: type('dspace/core/cache/object/REMOVE'), - RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS') + RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'), + ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), + APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH') }; /* tslint:disable:max-classes-per-file */ @@ -22,7 +25,7 @@ export class AddToObjectCacheAction implements Action { objectToCache: CacheableObject; timeAdded: number; msToLive: number; - requestHref: string; + requestUUID: string; }; /** @@ -39,8 +42,8 @@ export class AddToObjectCacheAction implements Action { * This isn't necessarily the same as the object's self * link, it could have been part of a list for example */ - constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestHref: string) { - this.payload = { objectToCache, timeAdded, msToLive, requestHref }; + constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestUUID: string) { + this.payload = { objectToCache, timeAdded, msToLive, requestUUID }; } } @@ -54,11 +57,11 @@ export class RemoveFromObjectCacheAction implements Action { /** * Create a new RemoveFromObjectCacheAction * - * @param uuid - * the UUID of the object to remove + * @param href + * the unique href of the object to remove */ - constructor(uuid: string) { - this.payload = uuid; + constructor(href: string) { + this.payload = href; } } @@ -79,6 +82,48 @@ export class ResetObjectCacheTimestampsAction implements Action { this.payload = newTimestamp; } } + +/** + * An ngrx action to add new operations to a specified cached object + */ +export class AddPatchObjectCacheAction implements Action { + type = ObjectCacheActionTypes.ADD_PATCH; + payload: { + href: string, + operations: Operation[] + }; + + /** + * Create a new AddPatchObjectCacheAction + * + * @param href + * the unique href of the object that should be updated + * @param operations + * the list of operations to add + */ + constructor(href: string, operations: Operation[]) { + this.payload = { href, operations }; + } +} + +/** + * An ngrx action to apply all existing operations to a specified cached object + */ +export class ApplyPatchObjectCacheAction implements Action { + type = ObjectCacheActionTypes.APPLY_PATCH; + payload: string; + + /** + * Create a new ApplyPatchObjectCacheAction + * + * @param href + * the unique href of the object that should be updated + */ + constructor(href: string) { + this.payload = href; + } +} + /* tslint:enable:max-classes-per-file */ /** @@ -87,4 +132,6 @@ export class ResetObjectCacheTimestampsAction implements Action { export type ObjectCacheAction = AddToObjectCacheAction | RemoveFromObjectCacheAction - | ResetObjectCacheTimestampsAction; + | ResetObjectCacheTimestampsAction + | AddPatchObjectCacheAction + | ApplyPatchObjectCacheAction; diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 2c059c4dd3..efa28d7249 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -2,9 +2,13 @@ import * as deepFreeze from 'deep-freeze'; import { objectCacheReducer } from './object-cache.reducer'; import { + AddPatchObjectCacheAction, AddToObjectCacheAction, - RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction + ApplyPatchObjectCacheAction, + RemoveFromObjectCacheAction, + ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { Operation } from 'fast-json-patch'; class NullAction extends RemoveFromObjectCacheAction { type = null; @@ -16,8 +20,11 @@ class NullAction extends RemoveFromObjectCacheAction { } describe('objectCacheReducer', () => { + const requestUUID1 = '8646169a-a8fc-4b31-a368-384c07867eb1'; + const requestUUID2 = 'bd36820b-4bf7-4d58-bd80-b832058b7279'; const selfLink1 = 'https://localhost:8080/api/core/items/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; const selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3'; + const newName = 'new different name'; const testState = { [selfLink1]: { data: { @@ -26,16 +33,20 @@ describe('objectCacheReducer', () => { }, timeAdded: new Date().getTime(), msToLive: 900000, - requestHref: selfLink1 + requestUUID: requestUUID1, + patches: [], + isDirty: false }, [selfLink2]: { data: { - self: selfLink2, + self: requestUUID2, foo: 'baz' }, timeAdded: new Date().getTime(), msToLive: 900000, - requestHref: selfLink2 + requestUUID: selfLink2, + patches: [], + isDirty: false } }; deepFreeze(testState); @@ -59,8 +70,8 @@ describe('objectCacheReducer', () => { const objectToCache = { self: selfLink1 }; const timeAdded = new Date().getTime(); const msToLive = 900000; - const requestHref = 'https://rest.api/endpoint/selfLink1'; - const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); + const requestUUID = requestUUID1; + const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID); const newState = objectCacheReducer(state, action); expect(newState[selfLink1].data).toEqual(objectToCache); @@ -72,8 +83,8 @@ describe('objectCacheReducer', () => { const objectToCache = { self: selfLink1, foo: 'baz', somethingElse: true }; const timeAdded = new Date().getTime(); const msToLive = 900000; - const requestHref = 'https://rest.api/endpoint/selfLink1'; - const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); + const requestUUID = requestUUID1; + const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID); const newState = objectCacheReducer(testState, action); /* tslint:disable:no-string-literal */ @@ -87,8 +98,8 @@ describe('objectCacheReducer', () => { const objectToCache = { self: selfLink1 }; const timeAdded = new Date().getTime(); const msToLive = 900000; - const requestHref = 'https://rest.api/endpoint/selfLink1'; - const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); + const requestUUID = requestUUID1; + const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID); deepFreeze(state); objectCacheReducer(state, action); @@ -132,4 +143,32 @@ describe('objectCacheReducer', () => { objectCacheReducer(testState, action); }); + it('should perform the ADD_PATCH action without affecting the previous state', () => { + const action = new AddPatchObjectCacheAction(selfLink1, [{ + op: 'replace', + path: '/name', + value: 'random string' + }]); + // testState has already been frozen above + objectCacheReducer(testState, action); + }); + + it('should when the ADD_PATCH action dispatched', () => { + const patch = [{ op: 'add', path: '/name', value: newName } as Operation]; + const action = new AddPatchObjectCacheAction(selfLink1, patch); + const newState = objectCacheReducer(testState, action); + expect(newState[selfLink1].patches.map((p) => p.operations)).toContain(patch); + }); + + it('should when the APPLY_PATCH action dispatched', () => { + const patch = [{ op: 'add', path: '/name', value: newName } as Operation]; + const addPatchAction = new AddPatchObjectCacheAction(selfLink1, patch); + const stateWithPatch = objectCacheReducer(testState, addPatchAction); + + const action = new ApplyPatchObjectCacheAction(selfLink1); + const newState = objectCacheReducer(stateWithPatch, action); + expect(newState[selfLink1].patches).toEqual([]); + expect((newState[selfLink1].data as any).name).toEqual(newName); + }); + }); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 3a1830e14a..982c77341e 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,10 +1,15 @@ import { - ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, - RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction + ObjectCacheAction, + ObjectCacheActionTypes, + AddToObjectCacheAction, + RemoveFromObjectCacheAction, + ResetObjectCacheTimestampsAction, + AddPatchObjectCacheAction, ApplyPatchObjectCacheAction } from './object-cache.actions'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; import { ResourceType } from '../shared/resource-type'; +import { applyPatch, Operation } from 'fast-json-patch'; export enum DirtyType { Created = 'Created', @@ -12,6 +17,21 @@ 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[]; +} + /** * An interface to represent objects that can be cached * @@ -35,7 +55,9 @@ export class ObjectCacheEntry implements CacheEntry { data: CacheableObject; timeAdded: number; msToLive: number; - requestHref: string; + requestUUID: string; + patches: Patch[] = []; + isDirty: boolean; } /** @@ -76,6 +98,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi return resetObjectCacheTimestamps(state, action as ResetObjectCacheTimestampsAction) } + case ObjectCacheActionTypes.ADD_PATCH: { + return addPatchObjectCache(state, action as AddPatchObjectCacheAction); + } + + case ObjectCacheActionTypes.APPLY_PATCH: { + return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction); + } + default: { return state; } @@ -93,12 +123,15 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi * the new state, with the object added, or overwritten. */ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState { + const existing = state[action.payload.objectToCache.self]; return Object.assign({}, state, { [action.payload.objectToCache.self]: { data: action.payload.objectToCache, timeAdded: action.payload.timeAdded, msToLive: action.payload.msToLive, - requestHref: action.payload.requestHref + requestUUID: action.payload.requestUUID, + isDirty: (hasValue(existing) ? isNotEmpty(existing.patches) : false), + patches: (hasValue(existing) ? existing.patches : []) } }); } @@ -143,3 +176,49 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject }); return newState; } + +/** + * Add the list of patch operations to a cached object + * + * @param state + * the current state + * @param action + * an AddPatchObjectCacheAction + * @return ObjectCacheState + * the new state, with the new operations added to the state of the specified ObjectCacheEntry + */ +function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCacheAction): ObjectCacheState { + const uuid = action.payload.href; + const operations = action.payload.operations; + const newState = Object.assign({}, state); + if (hasValue(newState[uuid])) { + const patches = newState[uuid].patches; + newState[uuid] = Object.assign({}, newState[uuid], { + patches: [...patches, { operations } as Patch], + isDirty: true + }); + } + return newState; +} + +/** + * Apply the list of patch operations to a cached object + * + * @param state + * the current state + * @param action + * an ApplyPatchObjectCacheAction + * @return ObjectCacheState + * the new state, with the new operations applied to the state of the specified ObjectCacheEntry + */ +function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObjectCacheAction): ObjectCacheState { + const uuid = action.payload; + const newState = Object.assign({}, state); + if (hasValue(newState[uuid])) { + // flatten two dimensional array + const flatPatch: Operation[] = [].concat(...newState[uuid].patches.map((patch) => patch.operations)); + const newData = applyPatch(newState[uuid].data, flatPatch, undefined, false); + newState[uuid] = Object.assign({}, newState[uuid], { data: newData.newDocument, patches: [] }); + } + return newState; +} diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 38d51f09b3..eae7c06be7 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -2,32 +2,52 @@ import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { ObjectCacheService } from './object-cache.service'; -import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; +import { + AddPatchObjectCacheAction, + 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 { RestRequestMethod } from '../data/rest-request-method'; +import { AddToSSBAction } from './server-sync-buffer.actions'; +import { Patch } from './object-cache.reducer'; describe('ObjectCacheService', () => { let service: ObjectCacheService; let store: Store; const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + const requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736'; const timestamp = new Date().getTime(); const msToLive = 900000; - const objectToCache = { + let objectToCache = { self: selfLink, type: ResourceType.Item }; - const cacheEntry = { - data: objectToCache, - timeAdded: timestamp, - msToLive: msToLive - }; - const invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }); + let cacheEntry; + let invalidCacheEntry; + const operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation]; + + function init() { + objectToCache = { + self: selfLink, + type: ResourceType.Item + }; + cacheEntry = { + data: objectToCache, + timeAdded: timestamp, + msToLive: msToLive + }; + invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }) + } beforeEach(() => { + init(); store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); service = new ObjectCacheService(store); @@ -39,8 +59,8 @@ describe('ObjectCacheService', () => { describe('add', () => { it('should dispatch an ADD action with the object to add, the time to live, and the current timestamp', () => { - service.add(objectToCache, msToLive, selfLink); - expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, selfLink)); + service.add(objectToCache, msToLive, requestUUID); + expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, requestUUID)); }); }); @@ -60,7 +80,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(); @@ -76,7 +96,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(); }); @@ -86,7 +106,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); @@ -127,4 +147,30 @@ describe('ObjectCacheService', () => { }); }); + describe('patch methods', () => { + it('should dispatch the correct actions when addPatch is called', () => { + service.addPatch(selfLink, operations); + expect(store.dispatch).toHaveBeenCalledWith(new AddPatchObjectCacheAction(selfLink, operations)); + expect(store.dispatch).toHaveBeenCalledWith(new AddToSSBAction(selfLink, RestRequestMethod.PATCH)); + }); + + it('isDirty should return true when the patches list in the cache entry is not empty', () => { + cacheEntry.patches = [ + { + operations: operations + } as Patch]; + const result = (service as any).isDirty(cacheEntry); + expect(result).toBe(true); + }); + + it('isDirty should return false when the patches list in the cache entry is empty', () => { + cacheEntry.patches = []; + const result = (service as any).isDirty(cacheEntry); + expect(result).toBe(false); + }); + it('should dispatch the correct actions when applyPatchesToCachedObject is called', () => { + (service as any).applyPatchesToCachedObject(selfLink); + expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink)); + }); + }); }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index dbe241ffb3..483de65b98 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,26 +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, first, 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 { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; -import { hasNoValue } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators'; +import { hasNoValue, hasValue, 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 { 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 { + AddPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, + RemoveFromObjectCacheAction +} from './object-cache.actions'; -function selfLinkFromUuidSelector(uuid: string): MemoizedSelector { - return pathSelector(coreSelector, 'index', IndexName.OBJECT, uuid); -} +import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; +import { AddToSSBAction } from './server-sync-buffer.actions'; -function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/object', selfLink); -} +/** + * The base selector function to select the object cache in the store + */ +const objectCacheSelector = createSelector( + coreSelector, + (state: CoreState) => state['cache/object'] +); + +/** + * 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 @@ -37,20 +55,18 @@ export class ObjectCacheService { * The object to add * @param msToLive * The number of milliseconds it should be cached for - * @param requestHref - * The selfLink of the request that resulted in this object - * This isn't necessarily the same as the object's self - * link, it could have been part of a list for example + * @param requestUUID + * The UUID of the request that resulted in this object */ - add(objectToCache: CacheableObject, msToLive: number, requestHref: string): void { - this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestHref)); + add(objectToCache: CacheableObject, msToLive: number, requestUUID: string): void { + this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestUUID)); } /** - * Remove the object with the supplied UUID from the cache + * Remove the object with the supplied href from the cache * - * @param uuid - * The UUID of the object to be removed + * @param href + * The unique href of the object to be removed */ remove(uuid: string): void { this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); @@ -59,36 +75,55 @@ 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) => { - const type: GenericConstructor = NormalizedObjectFactory.getConstructor(entry.data.type); - return Object.assign(new type(), entry.data) as T - })); + if (isNotEmpty(entry.patches)) { + const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); + const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; + return Object.assign({}, entry, { data: patchedData }); + } else { + return entry; + } + } + ), + map((entry: ObjectCacheEntry) => { + 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)), @@ -96,16 +131,32 @@ export class ObjectCacheService { ); } - getRequestHrefBySelfLink(selfLink: string): Observable { - return this.getEntry(selfLink).pipe( - map((entry: ObjectCacheEntry) => entry.requestHref), - distinctUntilChanged(),); + /** + * 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.getBySelfLink(selfLink).pipe( + map((entry: ObjectCacheEntry) => entry.requestUUID), + distinctUntilChanged()); } - getRequestHrefByUUID(uuid: string): Observable { + /** + * 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)), - mergeMap((selfLink: string) => this.getRequestHrefBySelfLink(selfLink)) + mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)) ); } @@ -128,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)) ); } @@ -148,7 +199,7 @@ export class ObjectCacheService { this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - first() + take(1) ).subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink)); return result; @@ -167,7 +218,7 @@ export class ObjectCacheService { let result = false; this.store.pipe(select(entryFromSelfLinkSelector(selfLink)), - first() + take(1) ).subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry)); return result; @@ -195,4 +246,39 @@ export class ObjectCacheService { } } + /** + * Add operations to the existing list of operations for an ObjectCacheEntry + * Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated + * @param {string} uuid + * the uuid of the ObjectCacheEntry + * @param {Operation[]} patch + * list of operations to perform + */ + public addPatch(selfLink: string, patch: Operation[]) { + this.store.dispatch(new AddPatchObjectCacheAction(selfLink, patch)); + this.store.dispatch(new AddToSSBAction(selfLink, RestRequestMethod.PATCH)); + } + + /** + * Check whether there are any unperformed operations for an ObjectCacheEntry + * + * @param entry + * the entry to check + * @return boolean + * false if the entry is there are no operations left in the ObjectCacheEntry, true otherwise + */ + private isDirty(entry: ObjectCacheEntry): boolean { + return isNotEmpty(entry.patches); + } + + /** + * Apply the existing operations on an ObjectCacheEntry in the store + * NB: this does not make any server side changes + * @param {string} uuid + * the uuid of the ObjectCacheEntry + */ + private applyPatchesToCachedObject(selfLink: string) { + this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); + } + } diff --git a/src/app/core/cache/response-cache.actions.ts b/src/app/core/cache/response-cache.actions.ts deleted file mode 100644 index 0389067690..0000000000 --- a/src/app/core/cache/response-cache.actions.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Action } from '@ngrx/store'; - -import { type } from '../../shared/ngrx/type'; -import { RestResponse } from './response-cache.models'; - -/** - * The list of ResponseCacheAction type definitions - */ -export const ResponseCacheActionTypes = { - ADD: type('dspace/core/cache/response/ADD'), - REMOVE: type('dspace/core/cache/response/REMOVE'), - RESET_TIMESTAMPS: type('dspace/core/cache/response/RESET_TIMESTAMPS') -}; - -/* tslint:disable:max-classes-per-file */ -export class ResponseCacheAddAction implements Action { - type = ResponseCacheActionTypes.ADD; - payload: { - key: string, - response: RestResponse - timeAdded: number; - msToLive: number; - }; - - constructor(key: string, response: RestResponse, timeAdded: number, msToLive: number) { - this.payload = { key, response, timeAdded, msToLive }; - } -} - -/** - * An ngrx action to remove a request from the cache - */ -export class ResponseCacheRemoveAction implements Action { - type = ResponseCacheActionTypes.REMOVE; - payload: string; - - /** - * Create a new ResponseCacheRemoveAction - * @param key - * The key of the request to remove - */ - constructor(key: string) { - this.payload = key; - } -} - -/** - * An ngrx action to reset the timeAdded property of all cached objects - */ -export class ResetResponseCacheTimestampsAction implements Action { - type = ResponseCacheActionTypes.RESET_TIMESTAMPS; - payload: number; - - /** - * Create a new ResetObjectCacheTimestampsAction - * - * @param newTimestamp - * the new timeAdded all objects should get - */ - constructor(newTimestamp: number) { - this.payload = newTimestamp; - } -} -/* tslint:enable:max-classes-per-file */ - -/** - * A type to encompass all ResponseCacheActions - */ -export type ResponseCacheAction - = ResponseCacheAddAction - | ResponseCacheRemoveAction - | ResetResponseCacheTimestampsAction; diff --git a/src/app/core/cache/response-cache.effects.spec.ts b/src/app/core/cache/response-cache.effects.spec.ts deleted file mode 100644 index 950049bfca..0000000000 --- a/src/app/core/cache/response-cache.effects.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { StoreActionTypes } from '../../store.actions'; -import { ResponseCacheEffects } from './response-cache.effects'; -import { ResetResponseCacheTimestampsAction } from './response-cache.actions'; - -describe('ResponseCacheEffects', () => { - let cacheEffects: ResponseCacheEffects; - let actions: Observable; - const timestamp = 10000; - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - ResponseCacheEffects, - provideMockActions(() => actions), - // other providers - ], - }); - - cacheEffects = TestBed.get(ResponseCacheEffects); - }); - - describe('fixTimestampsOnRehydrate$', () => { - - it('should return a RESET_TIMESTAMPS action in response to a REHYDRATE action', () => { - spyOn(Date.prototype, 'getTime').and.callFake(() => { - return timestamp; - }); - actions = hot('--a-', { a: { type: StoreActionTypes.REHYDRATE, payload: {} } }); - - const expected = cold('--b-', { b: new ResetResponseCacheTimestampsAction(new Date().getTime()) }); - - expect(cacheEffects.fixTimestampsOnRehydrate).toBeObservable(expected); - }); - }); -}); diff --git a/src/app/core/cache/response-cache.effects.ts b/src/app/core/cache/response-cache.effects.ts deleted file mode 100644 index 5a1e53e20c..0000000000 --- a/src/app/core/cache/response-cache.effects.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { map } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; - -import { ResetResponseCacheTimestampsAction } from './response-cache.actions'; -import { StoreActionTypes } from '../../store.actions'; - -@Injectable() -export class ResponseCacheEffects { - - /** - * When the store is rehydrated in the browser, set all cache - * timestamps to 'now', because the time zone of the server can - * differ from the client. - * - * This assumes that the server cached everything a negligible - * time ago, and will likely need to be revisited later - */ - @Effect() fixTimestampsOnRehydrate = this.actions$ - .pipe(ofType(StoreActionTypes.REHYDRATE), - map(() => new ResetResponseCacheTimestampsAction(new Date().getTime())) - ); - - constructor(private actions$: Actions,) { - } - -} diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts deleted file mode 100644 index 9b1b5b89eb..0000000000 --- a/src/app/core/cache/response-cache.models.ts +++ /dev/null @@ -1,179 +0,0 @@ -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 { 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'; - -/* tslint:disable:max-classes-per-file */ -export class RestResponse { - public toCache = true; - - constructor( - public isSuccessful: boolean, - public statusCode: string, - ) { - } -} - -export class DSOSuccessResponse extends RestResponse { - constructor( - public resourceSelfLinks: string[], - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -export class RegistryMetadataschemasSuccessResponse extends RestResponse { - constructor( - public metadataschemasResponse: RegistryMetadataschemasResponse, - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -export class RegistryMetadatafieldsSuccessResponse extends RestResponse { - constructor( - public metadatafieldsResponse: RegistryMetadatafieldsResponse, - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -export class RegistryBitstreamformatsSuccessResponse extends RestResponse { - constructor( - public bitstreamformatsResponse: RegistryBitstreamformatsResponse, - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -export class MetadataschemaSuccessResponse extends RestResponse { - constructor( - public metadataschema: MetadataSchema, - public statusCode: string - ) { - super(true, statusCode); - } -} - -export class SearchSuccessResponse extends RestResponse { - constructor( - public results: SearchQueryResponse, - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -export class FacetConfigSuccessResponse extends RestResponse { - constructor( - public results: SearchFilterConfig[], - public statusCode: string - ) { - super(true, statusCode); - } -} - -export class FacetValueMap { - [name: string]: FacetValueSuccessResponse -} - -export class FacetValueSuccessResponse extends RestResponse { - constructor( - public results: FacetValue[], - public statusCode: string, - public pageInfo?: PageInfo) { - super(true, statusCode); - } -} - -export class FacetValueMapSuccessResponse extends RestResponse { - constructor( - public results: FacetValueMap, - public statusCode: string, - ) { - super(true, statusCode); - } -} - -export class EndpointMap { - [linkPath: string]: string -} - -export class EndpointMapSuccessResponse extends RestResponse { - constructor( - public endpointMap: EndpointMap, - public statusCode: string, - ) { - super(true, statusCode); - } -} - -export class GenericSuccessResponse extends RestResponse { - constructor( - public payload: T, - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -export class ErrorResponse extends RestResponse { - errorMessage: string; - - constructor(error: RequestError) { - super(false, error.statusText); - console.error(error); - this.errorMessage = error.message; - } -} - -export class ConfigSuccessResponse extends RestResponse { - constructor( - public configDefinition: ConfigObject[], - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -export class AuthStatusResponse extends RestResponse { - public toCache = false; - - constructor( - public response: AuthStatus, - public statusCode: string - ) { - super(true, statusCode); - } -} - -export class IntegrationSuccessResponse extends RestResponse { - constructor( - public dataDefinition: IntegrationModel[], - public statusCode: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode); - } -} - -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/response-cache.reducer.spec.ts b/src/app/core/cache/response-cache.reducer.spec.ts deleted file mode 100644 index 9037b20030..0000000000 --- a/src/app/core/cache/response-cache.reducer.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as deepFreeze from 'deep-freeze'; - -import { responseCacheReducer, ResponseCacheState } from './response-cache.reducer'; - -import { - ResponseCacheRemoveAction, - ResetResponseCacheTimestampsAction, ResponseCacheAddAction -} from './response-cache.actions'; -import { RestResponse } from './response-cache.models'; - -class NullAction extends ResponseCacheRemoveAction { - type = null; - payload = null; - - constructor() { - super(null); - } -} - -describe('responseCacheReducer', () => { - const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c']; - const msToLive = 900000; - const uuids = [ - '9e32a2e2-6b91-4236-a361-995ccdc14c60', - '598ce822-c357-46f3-ab70-63724d02d6ad', - 'be8325f7-243b-49f4-8a4b-df2b793ff3b5' - ]; - const testState: ResponseCacheState = { - [keys[0]]: { - key: keys[0], - response: new RestResponse(true, '200'), - timeAdded: new Date().getTime(), - msToLive: msToLive - }, - [keys[1]]: { - key: keys[1], - response: new RestResponse(true, '200'), - timeAdded: new Date().getTime(), - msToLive: msToLive - } - }; - deepFreeze(testState); - const errorState: {} = { - [keys[0]]: { - errorMessage: 'error', - resourceUUIDs: uuids - } - }; - deepFreeze(errorState); - - it('should return the current state when no valid actions have been made', () => { - const action = new NullAction(); - const newState = responseCacheReducer(testState, action); - - expect(newState).toEqual(testState); - }); - - it('should start with an empty cache', () => { - const action = new NullAction(); - const initialState = responseCacheReducer(undefined, action); - - expect(initialState).toEqual(Object.create(null)); - }); - - describe('ADD', () => { - const addTimeAdded = new Date().getTime(); - const addMsToLive = 5; - const addResponse = new RestResponse(true, '200'); - const action = new ResponseCacheAddAction(keys[0], addResponse, addTimeAdded, addMsToLive); - - it('should perform the action without affecting the previous state', () => { - // testState has already been frozen above - responseCacheReducer(testState, action); - }); - - it('should add the response to the cached request', () => { - const newState = responseCacheReducer(testState, action); - expect(newState[keys[0]].timeAdded).toBe(addTimeAdded); - expect(newState[keys[0]].msToLive).toBe(addMsToLive); - expect(newState[keys[0]].response).toBe(addResponse); - }); - }); - - describe('REMOVE', () => { - it('should perform the action without affecting the previous state', () => { - const action = new ResponseCacheRemoveAction(keys[0]); - // testState has already been frozen above - responseCacheReducer(testState, action); - }); - - it('should remove the specified request from the cache', () => { - const action = new ResponseCacheRemoveAction(keys[0]); - const newState = responseCacheReducer(testState, action); - expect(testState[keys[0]]).not.toBeUndefined(); - expect(newState[keys[0]]).toBeUndefined(); - }); - - it('shouldn\'t do anything when the specified key isn\'t cached', () => { - const wrongKey = 'this isn\'t cached'; - const action = new ResponseCacheRemoveAction(wrongKey); - const newState = responseCacheReducer(testState, action); - expect(testState[wrongKey]).toBeUndefined(); - expect(newState).toEqual(testState); - }); - }); - - describe('RESET_TIMESTAMPS', () => { - const newTimeStamp = new Date().getTime(); - const action = new ResetResponseCacheTimestampsAction(newTimeStamp); - - it('should perform the action without affecting the previous state', () => { - // testState has already been frozen above - responseCacheReducer(testState, action); - }); - - it('should set the timestamp of all requests in the cache', () => { - const newState = responseCacheReducer(testState, action); - Object.keys(newState).forEach((key) => { - expect(newState[key].timeAdded).toEqual(newTimeStamp); - }); - }); - - }); -}); diff --git a/src/app/core/cache/response-cache.reducer.ts b/src/app/core/cache/response-cache.reducer.ts deleted file mode 100644 index 73c680c1f5..0000000000 --- a/src/app/core/cache/response-cache.reducer.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - ResponseCacheAction, ResponseCacheActionTypes, - ResponseCacheRemoveAction, ResetResponseCacheTimestampsAction, - ResponseCacheAddAction -} from './response-cache.actions'; -import { CacheEntry } from './cache-entry'; -import { hasValue } from '../../shared/empty.util'; -import { RestResponse } from './response-cache.models'; - -/** - * An entry in the ResponseCache - */ -export class ResponseCacheEntry implements CacheEntry { - key: string; - response: RestResponse; - timeAdded: number; - msToLive: number; -} - -/** - * The ResponseCache State - */ -export interface ResponseCacheState { - [key: string]: ResponseCacheEntry -} - -// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) -const initialState = Object.create(null); - -/** - * The ResponseCache Reducer - * - * @param state - * the current state - * @param action - * the action to perform on the state - * @return ResponseCacheState - * the new state - */ -export function responseCacheReducer(state = initialState, action: ResponseCacheAction): ResponseCacheState { - switch (action.type) { - - case ResponseCacheActionTypes.ADD: { - return addToCache(state, action as ResponseCacheAddAction); - } - - case ResponseCacheActionTypes.REMOVE: { - return removeFromCache(state, action as ResponseCacheRemoveAction); - } - - case ResponseCacheActionTypes.RESET_TIMESTAMPS: { - return resetResponseCacheTimestamps(state, action as ResetResponseCacheTimestampsAction) - } - - default: { - return state; - } - } -} - -function addToCache(state: ResponseCacheState, action: ResponseCacheAddAction): ResponseCacheState { - return Object.assign({}, state, { - [action.payload.key]: { - key: action.payload.key, - response: action.payload.response, - timeAdded: action.payload.timeAdded, - msToLive: action.payload.msToLive - } - }); -} - -/** - * Remove a request from the cache - * - * @param state - * the current state - * @param action - * an ResponseCacheRemoveAction - * @return ResponseCacheState - * the new state, with the request removed if it existed. - */ -function removeFromCache(state: ResponseCacheState, action: ResponseCacheRemoveAction): ResponseCacheState { - if (hasValue(state[action.payload])) { - const newCache = Object.assign({}, state); - delete newCache[action.payload]; - - return newCache; - } else { - return state; - } -} - -/** - * Set the timeAdded timestamp of every cached request to the specified value - * - * @param state - * the current state - * @param action - * a ResetResponseCacheTimestampsAction - * @return ResponseCacheState - * the new state, with all timeAdded timestamps set to the specified value - */ -function resetResponseCacheTimestamps(state: ResponseCacheState, action: ResetResponseCacheTimestampsAction): ResponseCacheState { - const newState = Object.create(null); - Object.keys(state).forEach((key) => { - newState[key] = Object.assign({}, state[key], { - timeAdded: action.payload - }); - }); - return newState; -} diff --git a/src/app/core/cache/response-cache.service.spec.ts b/src/app/core/cache/response-cache.service.spec.ts deleted file mode 100644 index 4fcd926343..0000000000 --- a/src/app/core/cache/response-cache.service.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Store } from '@ngrx/store'; - -import { ResponseCacheService } from './response-cache.service'; -import { of as observableOf } from 'rxjs'; -import { CoreState } from '../core.reducers'; -import { RestResponse } from './response-cache.models'; -import { ResponseCacheEntry } from './response-cache.reducer'; -import { first } from 'rxjs/operators'; -import * as ngrx from '@ngrx/store' -import { cold } from 'jasmine-marbles'; - -describe('ResponseCacheService', () => { - let service: ResponseCacheService; - let store: Store; - - const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c']; - const timestamp = new Date().getTime(); - const validCacheEntry = (key) => { - return { - key: key, - response: new RestResponse(true, '200'), - timeAdded: timestamp, - msToLive: 24 * 60 * 60 * 1000 // a day - } - }; - const invalidCacheEntry = (key) => { - return { - key: key, - response: new RestResponse(true, '200'), - timeAdded: 0, - msToLive: 0 - } - }; - - beforeEach(() => { - store = new Store(undefined, undefined, undefined); - spyOn(store, 'dispatch'); - service = new ResponseCacheService(store); - spyOn(Date.prototype, 'getTime').and.callFake(() => { - return timestamp; - }); - }); - - describe('get', () => { - it('should return an observable of the cached request with the specified key', () => { - spyOnProperty(ngrx, 'select').and.callFake(() => { - return () => { - return () => observableOf(validCacheEntry(keys[1])); - }; - }); - let testObj: ResponseCacheEntry; - service.get(keys[1]).pipe(first()).subscribe((entry) => { - testObj = entry; - }); - expect(testObj.key).toEqual(keys[1]); - }); - - it('should not return a cached request that has exceeded its time to live', () => { - spyOnProperty(ngrx, 'select').and.callFake(() => { - return () => { - return () => observableOf(invalidCacheEntry(keys[1])); - }; - }); - - let getObsHasFired = false; - const subscription = service.get(keys[1]).subscribe((entry) => getObsHasFired = true); - expect(getObsHasFired).toBe(false); - subscription.unsubscribe(); - }); - }); - - describe('has', () => { - it('should return true if the request with the supplied key is cached and still valid', () => { - spyOnProperty(ngrx, 'select').and.callFake(() => { - return () => { - return () => observableOf(validCacheEntry(keys[1])); - }; - }); - expect(service.has(keys[1])).toBe(true); - }); - - it('should return false if the request with the supplied key isn\'t cached', () => { - spyOnProperty(ngrx, 'select').and.callFake(() => { - return () => { - return () => observableOf(undefined); - }; - }); - expect(service.has(keys[1])).toBe(false); - }); - - it('should return false if the request with the supplied key is cached but has exceeded its time to live', () => { - spyOnProperty(ngrx, 'select').and.callFake(() => { - return () => { - return () => observableOf(invalidCacheEntry(keys[1])); - }; - }); - expect(service.has(keys[1])).toBe(false); - }); - }); -}); diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts deleted file mode 100644 index 21430d451c..0000000000 --- a/src/app/core/cache/response-cache.service.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { filter, take, distinctUntilChanged, first } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { MemoizedSelector, select, Store } from '@ngrx/store'; - -import { Observable } from 'rxjs'; - -import { ResponseCacheEntry } from './response-cache.reducer'; -import { hasNoValue } from '../../shared/empty.util'; -import { ResponseCacheRemoveAction, ResponseCacheAddAction } from './response-cache.actions'; -import { RestResponse } from './response-cache.models'; -import { coreSelector, CoreState } from '../core.reducers'; -import { pathSelector } from '../shared/selectors'; - -function entryFromKeySelector(key: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/response', key); -} - -/** - * A service to interact with the response cache - */ -@Injectable() -export class ResponseCacheService { - constructor( - private store: Store - ) { - } - - add(key: string, response: RestResponse, msToLive: number): Observable { - if (!this.has(key)) { - this.store.dispatch(new ResponseCacheAddAction(key, response, new Date().getTime(), msToLive)); - } - return this.get(key); - } - - /** - * Get an observable of the response with the specified key - * - * @param key - * the key of the response to get - * @return Observable - * an observable of the ResponseCacheEntry with the specified key - */ - get(key: string): Observable { - return this.store.pipe( - select(entryFromKeySelector(key)), - filter((entry: ResponseCacheEntry) => this.isValid(entry)), - distinctUntilChanged() - ) - } - - /** - * Check whether the response with the specified key is cached - * - * @param key - * the key of the response to check - * @return boolean - * true if the response with the specified key is cached, - * false otherwise - */ - has(key: string): boolean { - let result: boolean; - - this.store.pipe(select(entryFromKeySelector(key)), - first() - ).subscribe((entry: ResponseCacheEntry) => { - result = this.isValid(entry); - }); - - return result; - } - - remove(key: string): void { - if (this.has(key)) { - this.store.dispatch(new ResponseCacheRemoveAction(key)); - } - } - - /** - * Check whether a ResponseCacheEntry should still be cached - * - * @param entry - * the entry to check - * @return boolean - * false if the entry is null, undefined, or its time to - * live has been exceeded, true otherwise - */ - private isValid(entry: ResponseCacheEntry): boolean { - if (hasNoValue(entry)) { - return false; - } else { - const timeOutdated = entry.timeAdded + entry.msToLive; - const isOutDated = new Date().getTime() > timeOutdated; - if (isOutDated) { - this.store.dispatch(new ResponseCacheRemoveAction(entry.key)); - } - return !isOutDated; - } - } - -} diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts new file mode 100644 index 0000000000..a734eba812 --- /dev/null +++ b/src/app/core/cache/response.models.ts @@ -0,0 +1,257 @@ +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 '../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 { 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'; + +/* tslint:disable:max-classes-per-file */ +export class RestResponse { + public toCache = true; + public timeAdded: number; + + constructor( + public isSuccessful: boolean, + public statusCode: number, + public statusText: string + ) { + } +} + +export class DSOSuccessResponse extends RestResponse { + constructor( + public resourceSelfLinks: string[], + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + 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: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + 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: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + 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: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +/** + * A successful response containing exactly one MetadataSchema + */ +export class MetadataschemaSuccessResponse extends RestResponse { + constructor( + public metadataschema: MetadataSchema, + public statusCode: number, + public statusText: string, + ) { + 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: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class FacetConfigSuccessResponse extends RestResponse { + constructor( + public results: SearchFilterConfig[], + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); + } +} + +export class FacetValueMap { + [name: string]: FacetValueSuccessResponse +} + +export class FacetValueSuccessResponse extends RestResponse { + constructor( + public results: FacetValue[], + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo) { + super(true, statusCode, statusText); + } +} + +export class FacetValueMapSuccessResponse extends RestResponse { + constructor( + public results: FacetValueMap, + public statusCode: number, + public statusText: string + ) { + super(true, statusCode, statusText); + } +} + +export class EndpointMap { + [linkPath: string]: string +} + +export class EndpointMapSuccessResponse extends RestResponse { + constructor( + public endpointMap: EndpointMap, + public statusCode: number, + public statusText: string + ) { + super(true, statusCode, statusText); + } +} + +export class GenericSuccessResponse extends RestResponse { + constructor( + public payload: T, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class ErrorResponse extends RestResponse { + errorMessage: string; + + constructor(error: RequestError) { + super(false, error.statusCode, error.statusText); + console.error(error); + this.errorMessage = error.message; + } +} + +export class ConfigSuccessResponse extends RestResponse { + constructor( + public configDefinition: ConfigObject, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class AuthStatusResponse extends RestResponse { + public toCache = false; + + constructor( + public response: AuthStatus, + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); + } +} + +export class IntegrationSuccessResponse extends RestResponse { + constructor( + public dataDefinition: PaginatedList, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + 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); + } +} + +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/server-sync-buffer.actions.ts b/src/app/core/cache/server-sync-buffer.actions.ts new file mode 100644 index 0000000000..fd7e04ef8a --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.actions.ts @@ -0,0 +1,82 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; +import { RestRequestMethod } from '../data/rest-request-method'; + +/** + * The list of ServerSyncBufferAction type definitions + */ +export const ServerSyncBufferActionTypes = { + ADD: type('dspace/core/cache/syncbuffer/ADD'), + COMMIT: type('dspace/core/cache/syncbuffer/COMMIT'), + EMPTY: type('dspace/core/cache/syncbuffer/EMPTY'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * An ngrx action to add a new cached object to the server sync buffer + */ +export class AddToSSBAction implements Action { + type = ServerSyncBufferActionTypes.ADD; + payload: { + href: string, + method: RestRequestMethod + }; + + /** + * Create a new AddToSSBAction + * + * @param href + * the unique href of the cached object entry that should be updated + */ + constructor(href: string, method: RestRequestMethod) { + this.payload = { href, method: method }; + } +} + +/** + * An ngrx action to commit everything (for a certain method, when specified) in the ServerSyncBuffer to the server + */ +export class CommitSSBAction implements Action { + type = ServerSyncBufferActionTypes.COMMIT; + payload?: RestRequestMethod; + + /** + * Create a new CommitSSBAction + * + * @param method + * an optional method for which the ServerSyncBuffer should send its entries to the server + */ + constructor(method?: RestRequestMethod) { + this.payload = method; + } +} +/** + * An ngrx action to remove everything (for a certain method, when specified) from the ServerSyncBuffer to the server + */ +export class EmptySSBAction implements Action { + type = ServerSyncBufferActionTypes.EMPTY; + payload?: RestRequestMethod; + + /** + * Create a new EmptySSBAction + * + * @param method + * an optional method for which the ServerSyncBuffer should remove its entries + * if this parameter is omitted, the buffer will be emptied as a whole + */ + constructor(method?: RestRequestMethod) { + this.payload = method; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all ServerSyncBufferActions + */ +export type ServerSyncBufferAction + = AddToSSBAction + | CommitSSBAction + | EmptySSBAction diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts new file mode 100644 index 0000000000..773e0ab60c --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -0,0 +1,139 @@ +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 { RestRequestMethod } from '../data/rest-request-method'; +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 * as operators from 'rxjs/operators'; +import { spyOnOperator } from '../../shared/testing/utils'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { ApplyPatchObjectCacheAction } from './object-cache.actions'; + +describe('ServerSyncBufferEffects', () => { + let ssbEffects: ServerSyncBufferEffects; + let actions: Observable; + const testConfig = { + cache: + { + autoSync: + { + timePerMethod: {}, + defaultTime: 0 + } + } + }; + const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + let store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], + providers: [ + ServerSyncBufferEffects, + provideMockActions(() => actions), + { provide: GLOBAL_CONFIG, useValue: testConfig }, + { provide: RequestService, useValue: getMockRequestService() }, + { + provide: ObjectCacheService, useValue: { + getObjectBySelfLink: (link) => { + const object = new DSpaceObject(); + object.self = link; + return observableOf(object); + } + } + }, + { provide: Store, useClass: MockStore } + // other providers + ], + }); + + store = TestBed.get(Store); + ssbEffects = TestBed.get(ServerSyncBufferEffects); + }); + + describe('setTimeoutForServerSync', () => { + beforeEach(() => { + spyOnOperator(operators, 'delay').and.returnValue((v) => v); + }); + + it('should return a COMMIT action in response to an ADD action', () => { + actions = hot('a', { + a: { + type: ServerSyncBufferActionTypes.ADD, + payload: { href: selfLink, method: RestRequestMethod.PUT } + } + }); + + const expected = cold('b', { b: new CommitSSBAction(RestRequestMethod.PUT) }); + + expect(ssbEffects.setTimeoutForServerSync).toBeObservable(expected); + }); + }); + + describe('commitServerSyncBuffer', () => { + describe('when the buffer is not empty', () => { + beforeEach(() => { + store + .subscribe((state) => { + (state as any).core = Object({}); + (state as any).core['cache/syncbuffer'] = { + buffer: [{ + href: selfLink, + method: RestRequestMethod.PATCH + }] + }; + }); + }); + it('should return a list of actions in response to a COMMIT action', () => { + actions = hot('a', { + a: { + type: ServerSyncBufferActionTypes.COMMIT, + payload: RestRequestMethod.PATCH + } + }); + + const expected = cold('(bc)', { + b: new ApplyPatchObjectCacheAction(selfLink), + c: new EmptySSBAction(RestRequestMethod.PATCH) + }); + + expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); + }); + }); + + describe('when the buffer is empty', () => { + beforeEach(() => { + store + .subscribe((state) => { + (state as any).core = Object({}); + (state as any).core['cache/syncbuffer'] = { + buffer: [] + }; + }); + }); + it('should return a placeholder action in response to a COMMIT action', () => { + store.subscribe(); + actions = hot('a', { + a: { + type: ServerSyncBufferActionTypes.COMMIT, + payload: { method: RestRequestMethod.PATCH } + } + }); + const expected = cold('b', { b: { type: 'NO_ACTION' } }); + + expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); + }); + }); + }); +}); diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts new file mode 100644 index 0000000000..3aa6ad312f --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -0,0 +1,123 @@ +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, + EmptySSBAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +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'; +import { RequestService } from '../data/request.service'; +import { PutRequest } from '../data/request.models'; +import { ObjectCacheService } from './object-cache.service'; +import { ApplyPatchObjectCacheAction } from './object-cache.actions'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestRequestMethod } from '../data/rest-request-method'; + +@Injectable() +export class ServerSyncBufferEffects { + + /** + * When an ADDToSSBAction is dispatched + * Set a time out (configurable per method type) + * Then dispatch a CommitSSBAction + * When the delay is running, no new AddToSSBActions are processed in this effect + */ + @Effect() setTimeoutForServerSync = this.actions$ + .pipe( + ofType(ServerSyncBufferActionTypes.ADD), + exhaustMap((action: AddToSSBAction) => { + const autoSyncConfig = this.EnvConfig.cache.autoSync; + const timeoutInSeconds = autoSyncConfig.timePerMethod[action.payload.method] || autoSyncConfig.defaultTime; + return observableOf(new CommitSSBAction(action.payload.method)).pipe( + delay(timeoutInSeconds * 1000), + ) + }) + ); + + /** + * When a CommitSSBAction is dispatched + * Create a list of actions for each entry in the current buffer state to be dispatched + * When the list of actions is not empty, also dispatch an EmptySSBAction + * When the list is empty dispatch a NO_ACTION placeholder action + */ + @Effect() commitServerSyncBuffer = this.actions$ + .pipe( + ofType(ServerSyncBufferActionTypes.COMMIT), + switchMap((action: CommitSSBAction) => { + return this.store.pipe( + select(serverSyncBufferSelector()), + take(1), /* necessary, otherwise delay will not have any effect after the first run */ + switchMap((bufferState: ServerSyncBufferState) => { + const actions: Array> = bufferState.buffer + .filter((entry: ServerSyncBufferEntry) => { + /* If there's a request method, filter + If there's no filter, commit everything */ + if (hasValue(action.payload)) { + return entry.method === action.payload; + } + return true; + }) + .map((entry: ServerSyncBufferEntry) => { + if (entry.method === RestRequestMethod.PATCH) { + return this.applyPatch(entry.href); + } else { + /* TODO implement for other request method types */ + } + }); + + /* Add extra action to array, to make sure the ServerSyncBuffer is emptied afterwards */ + if (isNotEmpty(actions) && isNotUndefined(actions[0])) { + return observableCombineLatest(...actions).pipe( + switchMap((array) => [...array, new EmptySSBAction(action.payload)]) + ); + } else { + return observableOf({ type: 'NO_ACTION' }); + } + }) + ) + }) + ); + + /** + * private method to create an ApplyPatchObjectCacheAction based on a cache entry + * and to do the actual patch request to the server + * @param {string} href The self link of the cache entry + * @returns {Observable} ApplyPatchObjectCacheAction to be dispatched + */ + private applyPatch(href: string): Observable { + const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1)); + + return patchObject.pipe( + map((object) => { + const serializedObject = new DSpaceRESTv2Serializer(object.constructor as GenericConstructor<{}>).serialize(object); + + this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), href, serializedObject)); + + return new ApplyPatchObjectCacheAction(href) + }) + ) + } + + constructor(private actions$: Actions, + private store: Store, + private requestService: RequestService, + private objectCache: ObjectCacheService, + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { + + } +} + +export function serverSyncBufferSelector(): MemoizedSelector { + return createSelector(coreSelector, (state: CoreState) => state['cache/syncbuffer']); +} diff --git a/src/app/core/cache/server-sync-buffer.reducer.spec.ts b/src/app/core/cache/server-sync-buffer.reducer.spec.ts new file mode 100644 index 0000000000..8f1392c99d --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -0,0 +1,85 @@ +import * as deepFreeze from 'deep-freeze'; +import { RemoveFromObjectCacheAction } from './object-cache.actions'; +import { serverSyncBufferReducer } from './server-sync-buffer.reducer'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { AddToSSBAction, EmptySSBAction } from './server-sync-buffer.actions'; + +class NullAction extends RemoveFromObjectCacheAction { + type = null; + payload = null; + + constructor() { + super(null); + } +} + +describe('serverSyncBufferReducer', () => { + const selfLink1 = 'https://localhost:8080/api/core/items/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + const selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3'; + const testState = { + buffer: + [ + { + href: selfLink1, + method: RestRequestMethod.PATCH, + }, + { + href: selfLink2, + method: RestRequestMethod.GET, + } + ] + }; + const newSelfLink = 'https://localhost:8080/api/core/items/1ce6b5ae-97e1-4e5a-b4b0-f9029bad10c0'; + + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = serverSyncBufferReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty buffer array', () => { + const action = new NullAction(); + const initialState = serverSyncBufferReducer(undefined, action); + + expect(initialState).toEqual({ buffer: [] }); + }); + + it('should perform the ADD action without affecting the previous state', () => { + const action = new AddToSSBAction(selfLink1, RestRequestMethod.POST); + // testState has already been frozen above + serverSyncBufferReducer(testState, action); + }); + + it('should perform the EMPTY action without affecting the previous state', () => { + const action = new EmptySSBAction(); + // testState has already been frozen above + serverSyncBufferReducer(testState, action); + }); + + it('should empty the buffer if the EmptySSBAction is dispatched without a payload', () => { + const action = new EmptySSBAction(); + // testState has already been frozen above + const emptyState = serverSyncBufferReducer(testState, action); + expect(emptyState).toEqual({ buffer: [] }); + }); + + it('should empty the buffer partially if the EmptySSBAction is dispatched with a payload', () => { + const action = new EmptySSBAction(RestRequestMethod.PATCH); + // testState has already been frozen above + const emptyState = serverSyncBufferReducer(testState, action); + expect(emptyState).toEqual({ buffer: testState.buffer.filter((entry) => entry.method !== RestRequestMethod.PATCH) }); + }); + + it('should add an entry to the buffer if the AddSSBAction is dispatched', () => { + const action = new AddToSSBAction(newSelfLink, RestRequestMethod.PUT); + // testState has already been frozen above + const newState = serverSyncBufferReducer(testState, action); + expect(newState.buffer).toContain({ + href: newSelfLink, method: RestRequestMethod.PUT + }) + ; + }) +}); diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts new file mode 100644 index 0000000000..c86a0d5654 --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -0,0 +1,91 @@ +import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { + AddToSSBAction, + EmptySSBAction, + ServerSyncBufferAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; +import { RestRequestMethod } from '../data/rest-request-method'; + +/** + * An entry in the ServerSyncBufferState + * href: unique href of an ObjectCacheEntry + * method: RestRequestMethod type + */ +export class ServerSyncBufferEntry { + href: string; + method: RestRequestMethod; +} + +/** + * The ServerSyncBuffer State + * + * Consists list of ServerSyncBufferState + */ +export interface ServerSyncBufferState { + buffer: ServerSyncBufferEntry[]; +} + +const initialState: ServerSyncBufferState = { buffer: [] }; + +/** + * The ServerSyncBuffer Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return ServerSyncBufferState + * the new state + */ +export function serverSyncBufferReducer(state = initialState, action: ServerSyncBufferAction): ServerSyncBufferState { + switch (action.type) { + + case ServerSyncBufferActionTypes.ADD: { + return addToServerSyncQueue(state, action as AddToSSBAction) + } + + case ServerSyncBufferActionTypes.EMPTY: { + return emptyServerSyncQueue(state, action as EmptySSBAction); + } + default: { + return state; + } + } +} + +/** + * Add a new entry to the buffer with a specified method + * + * @param state + * the current state + * @param action + * an AddToSSBAction + * @return ServerSyncBufferState + * the new state, with a new entry added to the buffer + */ +function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBAction): ServerSyncBufferState { + const actionEntry = action.payload as ServerSyncBufferEntry; + if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) { + return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) }); + } +} + +/** + * Remove all ServerSyncBuffers entry from the buffer with a specified method + * If no method is specified, empty the whole buffer + * + * @param state + * the current state + * @param action + * an AddToSSBAction + * @return ServerSyncBufferState + * the new state, with a new entry added to the buffer + */ +function emptyServerSyncQueue(state: ServerSyncBufferState, action: EmptySSBAction): ServerSyncBufferState { + let newBuffer = []; + if (hasValue(action.payload)) { + newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); + } + return Object.assign({}, state, { buffer: newBuffer }); +} 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 654ee53651..7c69f1bdb3 100644 --- a/src/app/core/data/config-response-parsing.service.spec.ts +++ b/src/app/core/config/config-response-parsing.service.spec.ts @@ -1,14 +1,15 @@ -import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; +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 65% rename from src/app/core/data/config-response-parsing.service.ts rename to src/app/core/config/config-response-parsing.service.ts index 2b1b923625..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-cache.models'; +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')) { - const configDefinition = this.process(data.payload, request.href); - return new ConfigSuccessResponse(configDefinition, data.statusCode, this.processPageInfo(data.payload)); + 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, 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 46c8fd1859..87add6b656 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -1,7 +1,6 @@ -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 { ResponseCacheService } from '../cache/response-cache.service'; import { ConfigService } from './config.service'; import { RequestService } from '../data/request.service'; import { ConfigRequest, FindAllOptions } from '../data/request.models'; @@ -16,7 +15,6 @@ class TestService extends ConfigService { protected browseEndpoint = BROWSE; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); @@ -26,7 +24,6 @@ class TestService extends ConfigService { describe('ConfigService', () => { let scheduler: TestScheduler; let service: TestService; - let responseCache: ResponseCacheService; let requestService: RequestService; let halService: any; @@ -39,17 +36,8 @@ describe('ConfigService', () => { const scopedEndpoint = `${serviceEndpoint}/${scopeName}`; const searchEndpoint = `${serviceEndpoint}/${BROWSE}?uuid=${scopeID}`; - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: { response: { isSuccessful } } - }) - }); - } - function initTestService(): TestService { return new TestService( - responseCache, requestService, halService ); @@ -57,7 +45,6 @@ describe('ConfigService', () => { beforeEach(() => { scheduler = getTestScheduler(); - responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(); halService = new HALEndpointServiceStub(configEndpoint); service = initTestService(); diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index 872bc57c2b..340a7a97d6 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -1,24 +1,24 @@ -import { Observable, of as observableOf, throwError as observableThrowError, merge as observableMerge } from 'rxjs'; +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { ConfigSuccessResponse } from '../cache/response-cache.models'; +import { ConfigSuccessResponse } from '../cache/response.models'; import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ConfigData } from './config-data'; +import { getResponseFromEntry } from '../shared/operators'; export abstract class ConfigService { protected request: ConfigRequest; - protected abstract responseCache: ResponseCacheService; protected abstract requestService: RequestService; protected abstract linkPath: string; protected abstract browseEndpoint: string; protected abstract halService: HALEndpointService; protected getConfig(request: RestRequest): Observable { - const responses = this.responseCache.get(request.href).pipe(map((entry: ResponseCacheEntry) => entry.response)); + const responses = this.requestService.getByHref(request.href).pipe( + getResponseFromEntry() + ); const errorResponses = responses.pipe( filter((response) => !response.isSuccessful), mergeMap(() => observableThrowError(new Error(`Couldn't retrieve the config`))) @@ -94,7 +94,6 @@ export abstract class ConfigService { } public getConfigBySearch(options: FindAllOptions = {}): Observable { - console.log(this.halService.getEndpoint(this.linkPath)); return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getConfigSearchHref(endpoint, options)), filter((href: string) => isNotEmpty(href)), 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-definitions-config.service.ts b/src/app/core/config/submission-definitions-config.service.ts index 6cbe0c55b5..b7b0873c21 100644 --- a/src/app/core/config/submission-definitions-config.service.ts +++ b/src/app/core/config/submission-definitions-config.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { ConfigService } from './config.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,7 +10,6 @@ export class SubmissionDefinitionsConfigService extends ConfigService { protected browseEndpoint = 'search/findByCollection'; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); diff --git a/src/app/core/config/submission-forms-config.service.ts b/src/app/core/config/submission-forms-config.service.ts index 27eac78218..b688859ec9 100644 --- a/src/app/core/config/submission-forms-config.service.ts +++ b/src/app/core/config/submission-forms-config.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { ConfigService } from './config.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,7 +10,6 @@ export class SubmissionFormsConfigService extends ConfigService { protected browseEndpoint = ''; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); diff --git a/src/app/core/config/submission-sections-config.service.ts b/src/app/core/config/submission-sections-config.service.ts index 6d4d2ca825..c8bbc0dd97 100644 --- a/src/app/core/config/submission-sections-config.service.ts +++ b/src/app/core/config/submission-sections-config.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { ConfigService } from './config.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,7 +10,6 @@ export class SubmissionSectionsConfigService extends ConfigService { protected browseEndpoint = ''; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); 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 bc534a36b0..bb25c49a7a 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,14 +1,18 @@ import { ObjectCacheEffects } from './cache/object-cache.effects'; -import { ResponseCacheEffects } from './cache/response-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 = [ - ResponseCacheEffects, RequestEffects, ObjectCacheEffects, UUIDIndexEffects, - AuthEffects + AuthEffects, + JsonPatchOperationsEffects, + ServerSyncBufferEffects, + ObjectUpdatesEffects ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 73e97c7933..6e94028a0a 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'; @@ -32,19 +34,22 @@ import { ObjectCacheService } from './cache/object-cache.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; import { RequestService } from './data/request.service'; -import { ResponseCacheService } from './cache/response-cache.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; import { ServerResponseService } from '../shared/services/server-response.service'; 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'; @@ -57,13 +62,24 @@ 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 { 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'; const IMPORTS = [ CommonModule, @@ -92,7 +108,10 @@ const PROVIDERS = [ DynamicFormService, DynamicFormValidationService, FormBuilderService, + SectionFormOperationsService, FormService, + EpersonResponseParsingService, + GroupEpersonService, HALEndpointService, HostWindowService, ItemDataService, @@ -100,9 +119,9 @@ const PROVIDERS = [ ObjectCacheService, PaginationComponentOptions, RegistryService, + NormalizedObjectBuildService, RemoteDataBuildService, RequestService, - ResponseCacheService, EndpointMapResponseParsingService, FacetValueResponseParsingService, FacetValueMapResponseParsingService, @@ -110,7 +129,6 @@ const PROVIDERS = [ RegistryMetadataschemasResponseParsingService, RegistryMetadatafieldsResponseParsingService, RegistryBitstreamformatsResponseParsingService, - MetadataschemaParsingService, DebugResponseParsingService, SearchResponseParsingService, ServerResponseService, @@ -122,12 +140,28 @@ const PROVIDERS = [ RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, + SubmissionRestService, SubmissionSectionsConfigService, + SubmissionResponseParsingService, + SubmissionJsonPatchOperationsService, + JsonPatchOperationsBuilder, AuthorityService, IntegrationResponseParsingService, + MetadataschemaParsingService, UploaderService, UUIDService, + NotificationsService, + WorkspaceitemDataService, + WorkflowitemDataService, + UploaderService, + FileService, DSpaceObjectDataService, + DSOChangeAnalyzer, + DefaultChangeAnalyzer, + CSSVariableService, + MenuService, + ObjectUpdatesService, + SearchService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c764a2acff..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 { responseCacheReducer, ResponseCacheState } from './cache/response-cache.reducer'; 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 { - 'data/object': ObjectCacheState, - 'data/response': ResponseCacheState, + '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 = { - 'data/object': objectCacheReducer, - 'data/response': responseCacheReducer, + '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 63468295d4..6102f930b0 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -8,15 +8,7 @@ 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 */ export abstract class BaseResponseParsingService { @@ -25,16 +17,15 @@ export abstract class BaseResponseParsingService { protected abstract objectFactory: any; protected abstract toCache: boolean; - protected process(data: any, requestHref: string): any { - + protected process(data: any, requestUUID: string): any { if (isNotEmpty(data)) { if (hasNoValue(data) || (typeof data !== 'object')) { return data; - } else if (isPaginatedResponse(data)) { - return this.processPaginatedList(data, requestHref); + } else if (isRestPaginatedList(data)) { + return this.processPaginatedList(data, requestUUID); } else if (Array.isArray(data)) { - return this.processArray(data, requestHref); - } else if (isObjectLevel(data)) { + return this.processArray(data, requestUUID); + } else if (isRestDataObject(data)) { data = this.fixBadEPersonRestResponse(data); const object = this.deserialize(data); if (isNotEmpty(data._embedded)) { @@ -42,21 +33,21 @@ export abstract class BaseResponseParsingService { .keys(data._embedded) .filter((property) => data._embedded.hasOwnProperty(property)) .forEach((property) => { - const parsedObj = this.process(data._embedded[property], requestHref); + 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)) } } }); } - this.cache(object, requestHref); + this.cache(object, requestUUID); return object; } const result = {}; @@ -64,15 +55,14 @@ export abstract class BaseResponseParsingService { .filter((property) => data.hasOwnProperty(property)) .filter((property) => hasValue(data[property])) .forEach((property) => { - const obj = this.process(data[property], requestHref); - result[property] = obj; + result[property] = this.process(data[property], requestUUID); }); return result; } } - protected processPaginatedList(data: any, requestHref: string): PaginatedList { + protected processPaginatedList(data: any, requestUUID: string): PaginatedList { const pageInfo: PageInfo = this.processPageInfo(data); let list = data._embedded; @@ -80,14 +70,14 @@ export abstract class BaseResponseParsingService { if (!Array.isArray(list)) { list = this.flattenSingleKeyObject(list); } - const page: ObjectDomain[] = this.processArray(list, requestHref); - return new PaginatedList(pageInfo, page); + const page: ObjectDomain[] = this.processArray(list, requestUUID); + return new PaginatedList(pageInfo, page, ); } - protected processArray(data: any, requestHref: string): ObjectDomain[] { + protected processArray(data: any, requestUUID: string): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, requestHref)]; + array = [...array, this.process(datum, requestUUID)]; } ); return array; @@ -100,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}`); @@ -115,17 +104,17 @@ export abstract class BaseResponseParsingService { } } - protected cache(obj, requestHref) { + protected cache(obj, requestUUID) { if (this.toCache) { - this.addToObjectCache(obj, requestHref); + this.addToObjectCache(obj, requestUUID); } } - protected addToObjectCache(co: CacheableObject, requestHref: string): void { + protected addToObjectCache(co: CacheableObject, requestUUID: string): void { if (hasNoValue(co) || hasNoValue(co.self)) { throw new Error('The server returned an invalid object'); } - this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref); + this.objectCache.add(co, this.EnvConfig.cache.msToLive.default, requestUUID); } processPageInfo(payload: any): PageInfo { @@ -149,6 +138,10 @@ export abstract class BaseResponseParsingService { return obj[keys[0]]; } + protected retrieveObjectOrUrl(obj: any): any { + return this.toCache ? obj.self : obj; + } + // 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 { 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 dd04e4f2f5..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 @@ -1,5 +1,5 @@ import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; -import { ErrorResponse, GenericSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, GenericSuccessResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; import { BrowseEntriesRequest } from './request.models'; @@ -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 171def60df..4690d738ed 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -7,7 +7,7 @@ import { ErrorResponse, GenericSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} 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 { BrowseEntry } from '../shared/browse-entry.model'; @@ -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 6a141c01c4..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 @@ -1,5 +1,5 @@ import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; -import { ErrorResponse, GenericSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, GenericSuccessResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; import { BrowseEntriesRequest, BrowseItemsRequest } from './request.models'; @@ -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 e513ad0898..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-cache.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 2b1703e38f..c1b0566e0b 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,6 +1,6 @@ import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { BrowseEndpointRequest } from './request.models'; -import { GenericSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; +import { GenericSuccessResponse, ErrorResponse } from '../cache/response.models'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; @@ -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 8feb1bc82b..3c67b2b3eb 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -2,7 +2,7 @@ import { 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 { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BrowseDefinition } from '../shared/browse-definition.model'; @@ -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 7d1e463dbe..3d03b9397d 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,30 +1,37 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; 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 { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { Collection } from '../shared/collection.model'; 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 { 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 CollectionDataService extends ComColDataService { +export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; + protected forceBypassCache = false; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected cds: CommunityDataService, protected objectCache: ObjectCacheService, - protected halService: HALEndpointService + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer ) { super(); } + } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index dca7caedd4..7f628fc5b9 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -5,7 +5,6 @@ import { GlobalConfig } from '../../../config'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; @@ -13,72 +12,90 @@ import { FindAllOptions, FindByIDRequest } from './request.models'; import { RequestService } from './request.service'; 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 responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected EnvConfig: GlobalConfig, protected cds: CommunityDataService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { super(); } } + /* tslint:enable:max-classes-per-file */ describe('ComColDataService', () => { let scheduler: TestScheduler; let service: TestService; - let responseCache: ResponseCacheService; 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(), { scopeID: scopeID }); + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful } as any + } as RequestEntry) + }; const communitiesEndpoint = 'https://rest.api/core/communities'; 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 }) - }); - } - - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: { response: { isSuccessful } } - }) + getIDHref: cold('b-', { b: communityEndpoint }) }); } function initMockObjectCacheService(): ObjectCacheService { return jasmine.createSpyObj('objectCache', { - getByUUID: cold('d-', { + getObjectByUUID: cold('d-', { d: { _links: { [LINK_NAME]: scopedEndpoint @@ -90,18 +107,29 @@ describe('ComColDataService', () => { function initTestService(): TestService { return new TestService( - responseCache, requestService, rdbService, + dataBuildService, store, EnvConfig, cds, objectCache, halService, + notificationsService, + http, + comparator, LINK_NAME ); } + beforeEach(() => { + cds = initMockCommunityDataService(); + requestService = getMockRequestService(); + objectCache = initMockObjectCacheService(); + halService = mockHalService; + service = initTestService(); + }); + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); @@ -109,9 +137,8 @@ describe('ComColDataService', () => { it('should configure a new FindByIDRequest for the scope Community', () => { cds = initMockCommunityDataService(); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); objectCache = initMockObjectCacheService(); - responseCache = initMockResponseCacheService(true); service = initTestService(); const expected = new FindByIDRequest(requestService.generateRequestId(), communityEndpoint, scopeID); @@ -125,32 +152,30 @@ describe('ComColDataService', () => { describe('if the scope Community can be found', () => { beforeEach(() => { cds = initMockCommunityDataService(); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); objectCache = initMockObjectCacheService(); - responseCache = initMockResponseCacheService(true); service = initTestService(); }); 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', () => { const result = service.getBrowseEndpoint(options); - const expected = cold('--e-', { e: scopedEndpoint }); + const expected = '--e-'; - expect(result).toBeObservable(expected); + scheduler.expectObservable(result).toBe(expected, { e: scopedEndpoint }); }); }); describe('if the scope Community can\'t be found', () => { beforeEach(() => { cds = initMockCommunityDataService(); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(false)); objectCache = initMockObjectCacheService(); - responseCache = initMockResponseCacheService(false); service = initTestService(); }); @@ -163,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 95cfce6c70..9d82cc5047 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,17 +1,17 @@ -import { distinctUntilChanged, filter, map, mergeMap, 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 { isEmpty, isNotEmpty } from '../../shared/empty.util'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; 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 { 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; @@ -26,12 +26,12 @@ export abstract class ComColDataService } * an Observable containing the scoped URL */ - public getBrowseEndpoint(options: FindAllOptions = {}): Observable { + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { if (isEmpty(options.scopeID)) { - return this.halService.getEndpoint(this.linkPath); + return this.halService.getEndpoint(linkPath); } else { const scopeCommunityHrefObs = this.cds.getEndpoint().pipe( - mergeMap((endpoint: string) => 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) => { @@ -40,20 +40,21 @@ export abstract class ComColDataService this.responseCache.get(href)), - map((entry: ResponseCacheEntry) => entry.response)); + mergeMap((href: string) => this.requestService.getByHref(href)), + getResponseFromEntry() + ); const errorResponses = responses.pipe( filter((response) => !response.isSuccessful), mergeMap(() => observableThrowError(new Error(`The Community with scope ${options.scopeID} couldn't be retrieved`))) ); const successResponses = responses.pipe( filter((response) => response.isSuccessful), - mergeMap(() => this.objectCache.getByUUID(options.scopeID)), - map((nc: NormalizedCommunity) => nc._links[this.linkPath]), + mergeMap(() => this.objectCache.getObjectByUUID(options.scopeID)), + map((nc: NormalizedCommunity) => nc._links[linkPath]), filter((href) => isNotEmpty(href)) ); - return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged()); + return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged(), share()); } } } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 6edd7fc23d..75ef58b06b 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,12 +1,10 @@ - -import {mergeMap, filter, take} from 'rxjs/operators'; +import { filter, mergeMap, 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 { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; import { ComColDataService } from './comcol-data.service'; @@ -17,20 +15,28 @@ import { RemoteData } from './remote-data'; import { hasValue, isNotEmpty } 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 responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, - protected halService: HALEndpointService + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer ) { super(); } @@ -40,17 +46,15 @@ export class CommunityDataService extends ComColDataService>> { - const hrefObs = this.halService.getEndpoint(this.topLinkPath).pipe(filter((href: string) => isNotEmpty(href)), - mergeMap((endpoint: string) => this.getFindAllHref(options)),); - + const hrefObs = this.getFindAllHref(options, this.topLinkPath); hrefObs.pipe( filter((href: string) => hasValue(href)), - take(1),) + take(1)) .subscribe((href: string) => { const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); this.requestService.configure(request); }); - return this.rdbService.buildList(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 d65bad9bbf..4a244db24f 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -1,59 +1,94 @@ import { DataService } from './data.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; 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 { 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 { compare } from 'fast-json-patch'; +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 responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected linkPath: string, - protected halService: HALEndpointService + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer ) { super(); } - public getBrowseEndpoint(options: FindAllOptions): Observable { + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { return observableOf(endpoint); } } +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 responseCache = {} as ResponseCacheService; 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 */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; const store = {} as Store; function initTestService(): TestService { return new TestService( - responseCache, requestService, rdbService, + dataBuildService, store, endpoint, - halService + halService, + objectCache, + notificationsService, + http, + comparator, ); } - service = initTestService(); describe('getFindAllHref', () => { @@ -111,7 +146,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}`; @@ -120,5 +155,54 @@ describe('DataService', () => { }); }) }); + describe('patch', () => { + let operations; + let selfLink; + beforeEach(() => { + 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'); + }); + + it('should call addPatch on the object cache with the right parameters', () => { + service.patch(selfLink, operations); + expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + }); + }); + + describe('update', () => { + let operations; + let selfLink; + let dso; + let dso2; + const name1 = 'random string'; + const name2 = 'another random string'; + beforeEach(() => { + 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.metadata = [{ key: 'dc.title', value: name1 }]; + + dso2 = new DSpaceObject(); + dso2.self = selfLink; + dso2.metadata = [{ key: 'dc.title', value: name2 }]; + + 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).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).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 f52f61cea1..fc4da69a5c 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,110 +1,314 @@ -import { distinctUntilChanged, filter, take, first, map } from 'rxjs/operators'; -import { of as observableOf, Observable } from 'rxjs'; +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 { ResponseCacheService } from '../cache/response-cache.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 { 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 { - protected abstract responseCache: ResponseCacheService; +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): Observable + public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable - protected getFindAllHref(options: FindAllOptions = {}): 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).pipe(distinctUntilChanged()); + 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}`); } - if (hasValue(options.elementsPerPage)) { args.push(`size=${options.elementsPerPage}`); } - if (hasValue(options.sort)) { args.push(`sort=${options.sort.field},${options.sort.direction}`); } - if (hasValue(options.startsWith)) { 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)) - .subscribe((href: string) => { - const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); - this.requestService.configure(request); - }); - - return this.rdbService.buildList(hrefObs) as Observable>>; - } - - getFindByIDHref(endpoint, resourceID): string { - return `${endpoint}/${resourceID}`; - } - - findById(id: string): Observable> { - const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getFindByIDHref(endpoint, id))); - hrefObs.pipe( first((href: string) => hasValue(href))) .subscribe((href: string) => { - const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id); - this.requestService.configure(request); + const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request, this.forceBypassCache); }); - return this.rdbService.buildSingle(hrefObs); + return this.rdbService.buildList(hrefObs) as Observable>>; } - findByHref(href: string): Observable> { - this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)); - return this.rdbService.buildSingle(href); + /** + * 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> { + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + 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.forceBypassCache); + }); + + return this.rdbService.buildSingle(hrefObs); + } + + 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>>; + } + + /** + * Add a new patch to the object cache to a specified object + * @param {string} href The selflink of the object that will be patched + * @param {Operation[]} operations The patch operations to be performed + */ + patch(href: string, operations: Operation[]) { + this.objectCache.addPatch(href, operations); + } + + /** + * Add a new patch to the object cache + * 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: 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/debug-response-parsing.service.ts b/src/app/core/data/debug-response-parsing.service.ts index d530948559..174abec897 100644 --- a/src/app/core/data/debug-response-parsing.service.ts +++ b/src/app/core/data/debug-response-parsing.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { RestResponse } from '../cache/response-cache.models'; +import { RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; 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..1fd207d2bf --- /dev/null +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -0,0 +1,29 @@ +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'; +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 aff450781f..eb95cdae8a 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -7,12 +7,13 @@ import { NormalizedObject } from '../cache/models/normalized-object.model'; import { ResourceType } from '../shared/resource-type'; import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RestResponse, DSOSuccessResponse } from '../cache/response-cache.models'; +import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; 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 { @@ -23,14 +24,22 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem constructor( @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected objectCache: ObjectCacheService, - ) { super(); + ) { + super(); } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const processRequestDTO = this.process(data.payload, request.href); + 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; @@ -38,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 ef767c5d2d..a0bba214ae 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -6,6 +6,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; 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; @@ -13,6 +17,7 @@ describe('DSpaceObjectDataService', () => { let halService: HALEndpointService; let requestService: RequestService; let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; const testObject = { uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746' } as DSpaceObject; @@ -37,11 +42,21 @@ 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, - halService + dataBuildService, + objectCache, + halService, + notificationsService, + http, + comparator ) }); @@ -57,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 c44f4ce1d1..4f0653f416 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -2,8 +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 { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,25 +9,35 @@ import { DataService } from './data.service'; 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 responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, - protected halService: HALEndpointService) { + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { super(); } - getBrowseEndpoint(options: FindAllOptions): Observable { - return this.halService.getEndpoint(this.linkPath); + getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + return this.halService.getEndpoint(linkPath); } - getFindByIDHref(endpoint, resourceID): string { + getIDHref(endpoint, resourceID): string { return endpoint.replace(/\{\?uuid\}/,`?uuid=${resourceID}`); } } @@ -42,8 +50,13 @@ export class DSpaceObjectDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - this.dataService = new DataServiceImpl(null, requestService, rdbService, null, halService); + protected dataBuildService: NormalizedObjectBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + 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 b850e13932..080c665ccf 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@angular/core'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { ErrorResponse, RestResponse, EndpointMapSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, RestResponse, EndpointMapSuccessResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; @@ -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 b0d89fb03e..e65e317642 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core'; import { FacetConfigSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; @@ -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 8588e4aa0b..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 @@ -4,13 +4,11 @@ import { FacetValueMapSuccessResponse, FacetValueSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; 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 { PageInfo } from '../shared/page-info.model'; -import { isNotEmpty } from '../../shared/empty.util'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -37,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 bc3f4e5368..e7665ebed2 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -1,16 +1,9 @@ import { Inject, Injectable } from '@angular/core'; -import { - FacetValueMap, - FacetValueMapSuccessResponse, - FacetValueSuccessResponse, - RestResponse -} from '../cache/response-cache.models'; +import { FacetValueSuccessResponse, RestResponse } from '../cache/response.models'; 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 { PageInfo } from '../shared/page-info.model'; -import { isNotEmpty } from '../../shared/empty.util'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -33,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/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 8e2db15921..3553a63af4 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -3,22 +3,46 @@ import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { ItemDataService } from './item-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllOptions } from './request.models'; +import { FindAllOptions, RestRequest } from './request.models'; +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 = {} as RequestService; - const responseCache = {} as ResponseCacheService; + const requestService = { + generateRequestId(): string { + return scopeID; + }, + configure(request: RestRequest) { + // Do nothing + }, + getByHref(requestHref: string) { + const responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'OK'); + return observableOf(responseCacheEntry); + } + } as RequestService; const rdbService = {} as RemoteDataBuildService; + const store = {} as Store; - const halEndpointService = {} as HALEndpointService; + const objectCache = {} as ObjectCacheService; + const halEndpointService = { + getEndpoint(linkPath: string): Observable { + return cold('a', {a: itemEndpoint}); + } + } as HALEndpointService; const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; const options = Object.assign(new FindAllOptions(), { @@ -34,6 +58,12 @@ 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 ? @@ -46,12 +76,16 @@ describe('ItemDataService', () => { function initTestService() { return new ItemDataService( - responseCache, requestService, rdbService, + dataBuildService, store, bs, - halEndpointService + objectCache, + halEndpointService, + notificationsService, + http, + comparator ); } @@ -83,4 +117,49 @@ describe('ItemDataService', () => { }); }); }); + + describe('getItemWithdrawEndpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + + }); + + it('should return the endpoint to withdraw and reinstate items', () => { + const result = service.getItemWithdrawEndpoint(scopeID); + const expected = cold('a', {a: ScopedItemEndpoint}); + + expect(result).toBeObservable(expected); + }); + + it('should setWithDrawn', () => { + const expected = new RestResponse(true, 200, 'OK'); + const result = service.setWithDrawn(scopeID, true); + result.subscribe((v) => expect(v).toEqual(expected)); + + }); + }); + + describe('getItemDiscoverableEndpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + + }); + + it('should return the endpoint to make an item private or public', () => { + const result = service.getItemDiscoverableEndpoint(scopeID); + const expected = cold('a', {a: ScopedItemEndpoint}); + + expect(result).toBeObservable(expected); + }); + + it('should setDiscoverable', () => { + const expected = new RestResponse(true, 200, 'OK'); + const result = service.setDiscoverable(scopeID, false); + 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 84eca23507..1e6f3e50de 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,35 +1,43 @@ -import {Injectable} from '@angular/core'; -import {distinctUntilChanged, map, filter} from 'rxjs/operators'; -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 {ResponseCacheService} from '../cache/response-cache.service'; -import {CoreState} from '../core.reducers'; -import {Item} from '../shared/item.model'; -import {URLCombiner} from '../url-combiner/url-combiner'; +import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { Item } from '../shared/item.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; -import {DataService} from './data.service'; -import {RequestService} from './request.service'; -import {HALEndpointService} from '../shared/hal-endpoint.service'; -import {FindAllOptions, PostRequest, PutRequest, RestRequest} from './request.models'; -import {RestResponse} from '../cache/response-cache.models'; -import {configureRequest, getResponseFromSelflink} from '../shared/operators'; -import {ResponseCacheEntry} from '../cache/response-cache.reducer'; +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DeleteByIDRequest, FindAllOptions, PatchRequest, PutRequest, 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, getRequestFromRequestHref } from '../shared/operators'; +import { RequestEntry } from './request.reducer'; +import { RestResponse } from '../cache/response.models'; @Injectable() -export class ItemDataService extends DataService { +export class ItemDataService extends DataService { protected linkPath = 'items'; + protected forceBypassCache = false; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, private bs: BrowseService, - protected halService: HALEndpointService) { + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { super(); } @@ -39,33 +47,102 @@ export class ItemDataService extends DataService { * @param {FindAllOptions} options * @returns {Observable} */ - public getBrowseEndpoint(options: FindAllOptions = {}): Observable { + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { let field = 'dc.date.issued'; if (options.sort && options.sort.field) { field = options.sort.field; } - return this.bs.getBrowseURLFor(field, this.linkPath).pipe( + return this.bs.getBrowseURLFor(field, linkPath).pipe( filter((href: string) => isNotEmpty(href)), map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()), distinctUntilChanged(),); } + /** + * Get the endpoint for item withdrawal and reinstatement + * @param itemId + */ + public getItemWithdrawEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, itemId)) + ); + } + + /** + * Get the endpoint to make item private and public + * @param itemId + */ + public getItemDiscoverableEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, itemId)) + ); + } + + /** + * Set the isWithdrawn state of an item to a specified state + * @param itemId + * @param withdrawn + */ + public setWithDrawn(itemId: string, withdrawn: boolean) { + const patchOperation = [{ + op: 'replace', path: '/withdrawn', value: withdrawn + }]; + return this.getItemWithdrawEndpoint(itemId).pipe( + distinctUntilChanged(), + map((endpointURL: string) => + new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) + ), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) + ); + } + + /** + * Set the isDiscoverable state of an item to a specified state + * @param itemId + * @param discoverable + */ + public setDiscoverable(itemId: string, discoverable: boolean) { + const patchOperation = [{ + op: 'replace', path: '/discoverable', value: discoverable + }]; + return this.getItemDiscoverableEndpoint(itemId).pipe( + distinctUntilChanged(), + map((endpointURL: string) => + new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) + ), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) + ); + } + public getMoveItemEndpoint(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}/owningCollection/move/${collectionId ? `/${collectionId}` : ''}`) ); } public moveToCollection(itemId: string, collectionId: string): Observable { - return this.getMoveItemEndpoint(itemId, collectionId).pipe( - // isNotEmptyOperator(), - distinctUntilChanged(), - map((endpointURL: string) => new PutRequest(this.requestService.generateRequestId(), endpointURL)), - configureRequest(this.requestService), - map((request: RestRequest) => request.href), - getResponseFromSelflink(this.responseCache), - map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response) + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getMoveItemEndpoint(itemId, collectionId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PutRequest(requestId, href); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) ); } } 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 cdd87c19d4..f76d6ed2e3 100644 --- a/src/app/core/data/metadataschema-parsing.service.ts +++ b/src/app/core/data/metadataschema-parsing.service.ts @@ -4,7 +4,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; import { Injectable } from '@angular/core'; -import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models'; @Injectable() export class MetadataschemaParsingService implements ResponseParsingService { @@ -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/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..a13fb9487b --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -0,0 +1,269 @@ +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, tap } 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 + */ + 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..faecd231bc 100644 --- a/src/app/core/data/paginated-list.ts +++ b/src/app/core/data/paginated-list.ts @@ -1,5 +1,5 @@ import { PageInfo } from '../shared/page-info.model'; -import { hasValue } from '../../shared/empty.util'; +import { hasNoValue, hasValue } from '../../shared/empty.util'; export class PaginatedList { @@ -22,6 +22,9 @@ export class PaginatedList { if (hasValue(this.pageInfo) && hasValue(this.pageInfo.totalElements)) { return this.pageInfo.totalElements; } + if (hasNoValue(this.page)) { + return 0; + } return this.page.length; } @@ -81,4 +84,12 @@ 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; + } } diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts index a137b99079..ea8d1ea810 100644 --- a/src/app/core/data/parsing.service.ts +++ b/src/app/core/data/parsing.service.ts @@ -1,6 +1,6 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { RestRequest } from './request.models'; -import { RestResponse } from '../cache/response-cache.models'; +import { RestResponse } from '../cache/response.models'; export interface ResponseParsingService { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse; 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 d981a12719..899fee4d1e 100644 --- a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts +++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts @@ -1,4 +1,4 @@ -import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response.models'; import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; @@ -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 1fe8b1e15f..a4bed3240e 100644 --- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts +++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts @@ -1,7 +1,7 @@ import { RegistryMetadatafieldsSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; @@ -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 2bb1302450..d19b334131 100644 --- a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts +++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts @@ -1,4 +1,4 @@ -import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; @@ -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.actions.ts b/src/app/core/data/request.actions.ts index 436c365caa..2b2de13504 100644 --- a/src/app/core/data/request.actions.ts +++ b/src/app/core/data/request.actions.ts @@ -1,6 +1,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; import { RestRequest } from './request.models'; +import { RestResponse } from '../cache/response.models'; /** * The list of RequestAction type definitions @@ -8,7 +9,9 @@ import { RestRequest } from './request.models'; export const RequestActionTypes = { CONFIGURE: type('dspace/core/data/request/CONFIGURE'), EXECUTE: type('dspace/core/data/request/EXECUTE'), - COMPLETE: type('dspace/core/data/request/COMPLETE') + COMPLETE: type('dspace/core/data/request/COMPLETE'), + RESET_TIMESTAMPS: type('dspace/core/data/request/RESET_TIMESTAMPS'), + REMOVE: type('dspace/core/data/request/REMOVE') }; /* tslint:disable:max-classes-per-file */ @@ -43,7 +46,10 @@ export class RequestExecuteAction implements Action { */ export class RequestCompleteAction implements Action { type = RequestActionTypes.COMPLETE; - payload: string; + payload: { + uuid: string, + response: RestResponse + }; /** * Create a new RequestCompleteAction @@ -51,10 +57,50 @@ export class RequestCompleteAction implements Action { * @param uuid * the request's uuid */ - constructor(uuid: string) { - this.payload = uuid; + constructor(uuid: string, response: RestResponse) { + this.payload = { + uuid, + response + }; } } + +/** + * An ngrx action to reset the timeAdded property of all responses in the cached objects + */ +export class ResetResponseTimestampsAction implements Action { + type = RequestActionTypes.RESET_TIMESTAMPS; + payload: number; + + /** + * Create a new ResetResponseTimestampsAction + * + * @param newTimestamp + * the new timeAdded all objects should get + */ + constructor(newTimestamp: number) { + this.payload = newTimestamp; + } +} + +/** + * An ngrx action to remove a cached request + */ +export class RequestRemoveAction implements Action { + type = RequestActionTypes.REMOVE; + uuid: string; + + /** + * Create a new RequestRemoveAction + * + * @param uuid + * the request's uuid + */ + constructor(uuid: string) { + this.uuid = uuid + } +} + /* tslint:enable:max-classes-per-file */ /** @@ -63,4 +109,6 @@ export class RequestCompleteAction implements Action { export type RequestAction = RequestConfigureAction | RequestExecuteAction - | RequestCompleteAction; + | RequestCompleteAction + | ResetResponseTimestampsAction + | RequestRemoveAction; diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts index 29c9ced472..5e7bec698b 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -1,30 +1,33 @@ - -import {of as observableOf, Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { Inject, Injectable, Injector } from '@angular/core'; -import { Request } from '@angular/http'; -import { RequestArgs } from '@angular/http/src/interfaces'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { isNotEmpty } from '../../shared/empty.util'; -import { ErrorResponse, RestResponse } from '../cache/response-cache.models'; -import { ResponseCacheService } from '../cache/response-cache.service'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RequestActionTypes, RequestCompleteAction, RequestExecuteAction } from './request.actions'; +import { + RequestActionTypes, + RequestCompleteAction, + RequestExecuteAction, + ResetResponseTimestampsAction +} from './request.actions'; import { RequestError, RestRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; -import { catchError, flatMap, map, take, tap } from 'rxjs/operators'; +import { catchError, filter, flatMap, map, take, tap } from 'rxjs/operators'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; +import { StoreActionTypes } from '../../store.actions'; -export const addToResponseCacheAndCompleteAction = (request: RestRequest, responseCache: ResponseCacheService, envConfig: GlobalConfig) => - (source: Observable): Observable => +export const addToResponseCacheAndCompleteAction = (request: RestRequest, envConfig: GlobalConfig) => + (source: Observable): Observable => source.pipe( - tap((response: RestResponse) => responseCache.add(request.href, response, envConfig.cache.msToLive)), - map((response: RestResponse) => new RequestCompleteAction(request.uuid)) + map((response: RestResponse) => { + return new RequestCompleteAction(request.uuid, response) + }) ); @Injectable() @@ -37,6 +40,7 @@ export class RequestEffects { take(1) ); }), + filter((entry: RequestEntry) => hasValue(entry)), map((entry: RequestEntry) => entry.request), flatMap((request: RestRequest) => { let body; @@ -46,20 +50,32 @@ export class RequestEffects { } return this.restApi.request(request.method, request.href, body, request.options).pipe( map((data: DSpaceRESTV2Response) => this.injector.get(request.getResponseParser()).parse(request, data)), - addToResponseCacheAndCompleteAction(request, this.responseCache, this.EnvConfig), + addToResponseCacheAndCompleteAction(request, this.EnvConfig), catchError((error: RequestError) => observableOf(new ErrorResponse(error)).pipe( - addToResponseCacheAndCompleteAction(request, this.responseCache, this.EnvConfig) + addToResponseCacheAndCompleteAction(request, this.EnvConfig) )) ); }) ); + /** + * When the store is rehydrated in the browser, set all cache + * timestamps to 'now', because the time zone of the server can + * differ from the client. + * + * This assumes that the server cached everything a negligible + * time ago, and will likely need to be revisited later + */ + @Effect() fixTimestampsOnRehydrate = this.actions$ + .pipe(ofType(StoreActionTypes.REHYDRATE), + map(() => new ResetResponseTimestampsAction(new Date().getTime())) + ); + constructor( @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, private actions$: Actions, private restApi: DSpaceRESTv2Service, private injector: Injector, - private responseCache: ResponseCacheService, protected requestService: RequestService ) { } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index b87f9cefc8..d2cdd45a0a 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -1,63 +1,56 @@ import { SortOptions } from '../cache/models/sort-options.model'; import { GenericConstructor } from '../shared/generic-constructor'; -import { GlobalConfig } from '../../../config/global-config.interface'; -import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; 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 { HttpHeaders } from '@angular/common/http'; +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 { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service'; +import { MetadataschemaParsingService } from './metadataschema-parsing.service'; +import { MetadatafieldParsingService } from './metadatafield-parsing.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; /* tslint:disable:max-classes-per-file */ -/** - * Represents a Request Method. - * - * I didn't reuse the RequestMethod enum in @angular/http because - * it uses numbers. The string values here are more clear when - * debugging. - * - * The ones commented out are still unsupported in the rest of the codebase - */ -export enum RestRequestMethod { - Get = 'GET', - Post = 'POST', - Put = 'PUT', - Delete = 'DELETE', - Options = 'OPTIONS', - Head = 'HEAD', - Patch = 'PATCH' -} - export abstract class RestRequest { + public responseMsToLive = 0; constructor( public uuid: string, public href: string, - public method: RestRequestMethod = RestRequestMethod.Get, + public method: RestRequestMethod = RestRequestMethod.GET, public body?: any, - public options?: HttpOptions + public options?: HttpOptions, ) { } getResponseParser(): GenericConstructor { return DSOResponseParsingService; } + + get toCache(): boolean { + return this.responseMsToLive > 0; + } } export class GetRequest extends RestRequest { + public responseMsToLive = 60 * 15 * 1000; + constructor( public uuid: string, public href: string, public body?: any, - public options?: HttpOptions + public options?: HttpOptions, ) { - super(uuid, href, RestRequestMethod.Get, body) + super(uuid, href, RestRequestMethod.GET, body, options) } } @@ -68,7 +61,7 @@ export class PostRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Post, body) + super(uuid, href, RestRequestMethod.POST, body) } } @@ -79,7 +72,7 @@ export class PutRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Put, body) + super(uuid, href, RestRequestMethod.PUT, body) } } @@ -90,7 +83,7 @@ export class DeleteRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Delete, body) + super(uuid, href, RestRequestMethod.DELETE, body) } } @@ -101,7 +94,7 @@ export class OptionsRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Options, body) + super(uuid, href, RestRequestMethod.OPTIONS, body) } } @@ -112,7 +105,7 @@ export class HeadRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Head, body) + super(uuid, href, RestRequestMethod.HEAD, body) } } @@ -123,7 +116,7 @@ export class PatchRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Patch, body) + super(uuid, href, RestRequestMethod.PATCH, body) } } @@ -142,6 +135,7 @@ export class FindAllOptions { elementsPerPage?: number; currentPage?: number; sort?: SortOptions; + searchParams?: SearchParam[]; startsWith?: string; } @@ -157,11 +151,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 { @@ -192,8 +186,8 @@ export class BrowseItemsRequest 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 { @@ -230,7 +224,155 @@ export class IntegrationRequest extends GetRequest { return IntegrationResponseParsingService; } } + +/** + * 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 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 bd8fad5de7..65a4ddba17 100644 --- a/src/app/core/data/request.reducer.spec.ts +++ b/src/app/core/data/request.reducer.spec.ts @@ -2,16 +2,20 @@ import * as deepFreeze from 'deep-freeze'; import { requestReducer, RequestState } from './request.reducer'; import { - RequestCompleteAction, RequestConfigureAction, RequestExecuteAction + RequestCompleteAction, + RequestConfigureAction, + RequestExecuteAction, RequestRemoveAction, ResetResponseTimestampsAction } from './request.actions'; -import { GetRequest, RestRequest } from './request.models'; +import { GetRequest } from './request.models'; +import { RestResponse } from '../cache/response.models'; +const response = new RestResponse(true, 200, 'OK'); class NullAction extends RequestCompleteAction { type = null; payload = null; constructor() { - super(null); + super(null, null); } } @@ -25,7 +29,8 @@ describe('requestReducer', () => { request: new GetRequest(id1, link1), requestPending: false, responsePending: false, - completed: false + completed: false, + response: undefined } }; deepFreeze(testState); @@ -56,6 +61,7 @@ describe('requestReducer', () => { expect(newState[id2].requestPending).toEqual(true); expect(newState[id2].responsePending).toEqual(false); expect(newState[id2].completed).toEqual(false); + expect(newState[id2].response).toEqual(undefined); }); it('should set \'requestPending\' to false, \'responsePending\' to true and leave \'completed\' untouched for the given RestRequest in the state, in response to an EXECUTE action', () => { @@ -69,11 +75,13 @@ describe('requestReducer', () => { expect(newState[id1].requestPending).toEqual(false); expect(newState[id1].responsePending).toEqual(true); expect(newState[id1].completed).toEqual(state[id1].completed); + expect(newState[id1].response).toEqual(undefined) }); + it('should leave \'requestPending\' untouched, set \'responsePending\' to false and \'completed\' to true for the given RestRequest in the state, in response to a COMPLETE action', () => { const state = testState; - const action = new RequestCompleteAction(id1); + const action = new RequestCompleteAction(id1, response); const newState = requestReducer(state, action); expect(newState[id1].request.uuid).toEqual(id1); @@ -81,5 +89,34 @@ 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.timeAdded).toBeTruthy() + }); + + it('should leave \'requestPending\' untouched, should leave \'responsePending\' untouched and leave \'completed\' untouched, but update the response\'s timeAdded for the given RestRequest in the state, in response to a COMPLETE action', () => { + const update = Object.assign({}, testState[id1], {response}); + const state = Object.assign({}, testState, {[id1]: update}); + const timeStamp = 1000; + const action = new ResetResponseTimestampsAction(timeStamp); + const newState = requestReducer(state, action); + + expect(newState[id1].request.uuid).toEqual(state[id1].request.uuid); + expect(newState[id1].request.href).toEqual(state[id1].request.href); + expect(newState[id1].requestPending).toEqual(state[id1].requestPending); + expect(newState[id1].responsePending).toEqual(state[id1].responsePending); + expect(newState[id1].completed).toEqual(state[id1].completed); + expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful); + 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 3ac35d2741..e324e4d5a2 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -1,14 +1,16 @@ import { RequestActionTypes, RequestAction, RequestConfigureAction, - RequestExecuteAction, RequestCompleteAction + RequestExecuteAction, RequestCompleteAction, ResetResponseTimestampsAction, RequestRemoveAction } from './request.actions'; import { RestRequest } from './request.models'; +import { RestResponse } from '../cache/response.models'; export class RequestEntry { request: RestRequest; requestPending: boolean; responsePending: boolean; completed: boolean; + response: RestResponse } export interface RequestState { @@ -32,6 +34,13 @@ export function requestReducer(state = initialState, action: RequestAction): Req case RequestActionTypes.COMPLETE: { return completeRequest(state, action as RequestCompleteAction); } + case RequestActionTypes.RESET_TIMESTAMPS: { + return resetResponseTimestamps(state, action as ResetResponseTimestampsAction); + } + + case RequestActionTypes.REMOVE: { + return removeRequest(state, action as RequestRemoveAction); + } default: { return state; @@ -45,18 +54,19 @@ function configureRequest(state: RequestState, action: RequestConfigureAction): request: action.payload, requestPending: true, responsePending: false, - completed: false + completed: false, } }); } function executeRequest(state: RequestState, action: RequestExecuteAction): RequestState { - return Object.assign({}, state, { + const obs = Object.assign({}, state, { [action.payload]: Object.assign({}, state[action.payload], { requestPending: false, responsePending: true }) }); + return obs; } /** @@ -70,10 +80,47 @@ function executeRequest(state: RequestState, action: RequestExecuteAction): Requ * the new state, with the response added to the request */ function completeRequest(state: RequestState, action: RequestCompleteAction): RequestState { + const time = new Date().getTime(); return Object.assign({}, state, { - [action.payload]: Object.assign({}, state[action.payload], { + [action.payload.uuid]: Object.assign({}, state[action.payload.uuid], { responsePending: false, - completed: true + completed: true, + response: Object.assign({}, action.payload.response, { timeAdded: time }) }) }); } + +/** + * 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], + { response: Object.assign({}, state[key].response, { timeAdded: action.payload }) } + ); + }); + 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) { + if (value !== action.uuid) { + newState[value] = state[value]; + } + } + return newState; +} diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index 39f29a9beb..e2bc04040f 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,14 +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 { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ResponseCacheService } from '../cache/response-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, @@ -20,16 +20,13 @@ 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'; describe('RequestService', () => { let scheduler: TestScheduler; let service: RequestService; let serviceAsAny: any; let objectCache: ObjectCacheService; - let responseCache: ResponseCacheService; let uuidService: UUIDService; let store: Store; @@ -43,20 +40,17 @@ describe('RequestService', () => { const testHeadRequest = new HeadRequest(testUUID, testHref); const testPatchRequest = new PatchRequest(testUUID, testHref); let selectSpy; + beforeEach(() => { scheduler = getTestScheduler(); objectCache = getMockObjectCacheService(); (objectCache.hasBySelfLink as any).and.returnValue(false); - responseCache = getMockResponseCacheService(); - (responseCache.has as any).and.returnValue(false); - (responseCache.get as any).and.returnValue(observableOf(undefined)); - uuidService = getMockUUIDService(); store = new Store(new BehaviorSubject({}), new ActionsSubject(), null); - selectSpy = spyOnProperty(ngrx, 'select') + selectSpy = spyOnProperty(ngrx, 'select'); selectSpy.and.callFake(() => { return () => { return () => cold('a', { a: undefined }); @@ -65,9 +59,9 @@ describe('RequestService', () => { service = new RequestService( objectCache, - responseCache, uuidService, - store + store, + undefined ); serviceAsAny = service as any; }); @@ -178,11 +172,8 @@ describe('RequestService', () => { it('should return an Observable of undefined', () => { const result = service.getByUUID(testUUID); - const expected = cold('b', { - b: undefined - }); - expect(result).toBeObservable(expected); + scheduler.expectObservable(result).toBe('b', { b: undefined }); }); }); @@ -298,36 +289,16 @@ 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', () => { describe('when the request is cached', () => { describe('in the ObjectCache', () => { beforeEach(() => { - (objectCache.hasBySelfLink as any).and.returnValues(true); + (objectCache.hasBySelfLink as any).and.returnValue(true); + spyOn(serviceAsAny, 'hasByHref').and.returnValue(false); }); it('should return true', () => { @@ -337,60 +308,16 @@ describe('RequestService', () => { expect(result).toEqual(expected); }); }); - describe('in the responseCache', () => { + describe('in the request cache', () => { beforeEach(() => { - (responseCache.has as any).and.returnValues(true); + (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(() => { - (responseCache.get as any).and.returnValues(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); - - 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.returnValues(false); - (responseCache.has as any).and.returnValues(true); - (responseCache.get as any).and.returnValues(observableOf({ - response: { - isSuccessful: true - } - } - )); - }); - - it('should return true', () => { - const result = serviceAsAny.isCachedOrPending(testGetRequest); - const expected = true; - - expect(result).toEqual(expected); - }); + expect(result).toEqual(expected); }); }); }); @@ -434,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,4 +414,129 @@ describe('RequestService', () => { }); }); }); + + describe('isValid', () => { + describe('when the given entry has no value', () => { + let valid; + beforeEach(() => { + const entry = undefined; + valid = serviceAsAny.isValid(entry); + }); + it('return an observable emitting false', () => { + expect(valid).toBe(false); + }) + }); + + 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(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); + }); + it('return an observable emitting false', () => { + expect(valid).toBe(false); + }) + }); + + 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(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); + }); + it('return an observable emitting false', () => { + expect(valid).toBe(false); + }) + }); + + 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(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); + }); + + it('return an observable emitting 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 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(requestEntry)); + valid = serviceAsAny.isValid(requestEntry); + }); + + it('return an observable emitting 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 101825e3db..fd463047f1 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,45 +1,95 @@ -import { Observable, merge as observableMerge } from 'rxjs'; -import { filter, first, map, mergeMap, partition, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { MemoizedSelector, select, Store } from '@ngrx/store'; -import { hasValue } from '../../shared/empty.util'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { Observable, race as observableRace } from 'rxjs'; +import { filter, mergeMap, take } from 'rxjs/operators'; + +import { AppState } from '../../app.reducer'; +import { hasValue, 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-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { coreSelector, CoreState } from '../core.reducers'; -import { IndexName } 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 } from './request.actions'; -import { GetRequest, RestRequest, RestRequestMethod } from './request.models'; +import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions'; +import { GetRequest, RestRequest } from './request.models'; +import { RequestEntry, RequestState } from './request.reducer'; +import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; +import { RestRequestMethod } from './rest-request-method'; +import { AddToIndexAction, RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { coreSelector } from '../core.selectors'; -import { RequestEntry } from './request.reducer'; +/** + * 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 name The name of the index substate we're fetching request UUIDs 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[] = []; constructor(private objectCache: ObjectCacheService, - private responseCache: ResponseCacheService, private uuidService: UUIDService, - private store: 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 store: Store, + 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)) { @@ -53,55 +103,103 @@ 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 this.store.pipe(select(this.entryFromUUIDSelector(uuid))); + return observableRace( + this.store.pipe(select(entryFromUUIDSelector(uuid))), + this.store.pipe( + select(originalRequestUUIDFromRequestUUIDSelector(uuid)), + mergeMap((originalUUID) => { + return this.store.pipe(select(entryFromUUIDSelector(originalUUID))) + }, + )) + ); } + /** + * 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)) ); } - // TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed + /** + * Configure a certain request + * Used to make sure a request is in the cache + * @param {RestRequest} request The request to send out + * @param {boolean} forceBypassCache When true, a new request is always dispatched + */ + // TODO to review "forceBypassCache" 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; + const isGetRequest = request.method === RestRequestMethod.GET; if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { this.dispatchRequest(request); if (isGetRequest && !forceBypassCache) { this.trackRequestsOnTheirWayToTheStore(request); } + } else { + this.getByHref(request.href).pipe( + filter((entry) => hasValue(entry)), + take(1) + ).subscribe((entry) => { + return this.store.dispatch(new AddToIndexAction(IndexName.UUID_MAPPING, request.uuid, entry.request.uuid)) + } + ) } } - private isCachedOrPending(request: GetRequest) { - let isCached = this.objectCache.hasBySelfLink(request.href); - if (!isCached && this.responseCache.has(request.href)) { - const responses = this.responseCache.get(request.href).pipe( - take(1), - map((entry: ResponseCacheEntry) => entry.response) - ); + /** + * Remove all request cache providing (part of) the href + * This also includes href-to-uuid index cache + * @param href A substring of the request(s) href + */ + removeByHrefSubstring(href: string) { + this.store.pipe( + select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)), + take(1) + ).subscribe((uuids: string[]) => { + for (const uuid of uuids) { + this.removeByUuid(uuid); + } + }); + this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((reqHref: string) => reqHref.indexOf(href) < 0); + this.indexStore.dispatch(new RemoveFromIndexBySubstringAction(IndexName.REQUEST, href)); + } - 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)); + /** + * Remove request cache using the request's UUID + * @param uuid + */ + removeByUuid(uuid: string) { + this.store.dispatch(new RequestRemoveAction(uuid)); + } + + /** + * Check if a request is in the cache or if it's still pending + * @param {GetRequest} request The request to check + * @returns {boolean} True if the request is cached or still pending + */ + private isCachedOrPending(request: GetRequest): boolean { + const inReqCache = this.hasByHref(request.href); + const inObjCache = this.objectCache.hasBySelfLink(request.href); + const isCached = inReqCache || inObjCache; - observableMerge(errorResponses, otherSuccessResponses, dsoSuccessResponses).subscribe((c) => isCached = c); - } const isPending = this.isPending(request); return isCached || isPending; } + /** + * Configure and execute the request + * @param {RestRequest} request to dispatch + */ private dispatchRequest(request: RestRequest) { this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestExecuteAction(request.uuid)); @@ -116,11 +214,56 @@ 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) => { this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== 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 + */ + commit(method?: RestRequestMethod) { + this.store.dispatch(new CommitSSBAction(method)) + } + + /** + * Check whether a cached response should still be valid + * + * @param entry + * the entry to check + * @return boolean + * false if the uuid has no value, the response was not successful or its time to + * live was exceeded, true otherwise + */ + 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 { + 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/rest-request-method.ts b/src/app/core/data/rest-request-method.ts new file mode 100644 index 0000000000..03ae7ad0c4 --- /dev/null +++ b/src/app/core/data/rest-request-method.ts @@ -0,0 +1,18 @@ +/** + * Represents a Request Method. + * + * I didn't reuse the RequestMethod enum in @angular/http because + * it uses numbers. The string values here are more clear when + * debugging. + * + * The ones commented out are still unsupported in the rest of the codebase + */ +export enum RestRequestMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + OPTIONS = 'OPTIONS', + HEAD = 'HEAD', + PATCH = 'PATCH' +} diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 4039b8f761..0ca793c5ae 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { RestResponse, SearchSuccessResponse } from '../cache/response-cache.models'; +import { RestResponse, SearchSuccessResponse } from '../cache/response.models'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; @@ -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 { @@ -16,17 +16,17 @@ export class SearchResponseParsingService implements ResponseParsingService { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const payload = data.payload._embedded.searchResult; - const hitHighlights = payload._embedded.objects + 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 @@ -38,7 +38,8 @@ export class SearchResponseParsingService implements ResponseParsingService { .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], []); @@ -55,6 +56,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..18b9090844 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 @@ -2,12 +2,17 @@ import { TestBed, inject } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { DSpaceRESTv2Service } from './dspace-rest-v2.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; 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,25 +35,26 @@ 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.error).toBe(mockError); + expect(err).toEqual(mockError); }); - const req = httpMock.expectOne(url); expect(req.request.method).toBe('GET'); req.error(mockError); @@ -65,4 +71,15 @@ describe('DSpaceRESTv2Service', () => { expect(req.request.method).toBe('GET'); req.error(mockError); }); + + 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); + }); + }); }); 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 1570613c17..a2a9f2530c 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,13 @@ import {throwError as observableThrowError, Observable } from 'rxjs'; import {catchError, map} from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Request } from '@angular/http'; import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http' -import { RestRequestMethod } from '../data/request.models'; import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; import { HttpObserve } from '@angular/common/http/src/client'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { isNotEmpty } from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; export interface HttpOptions { body?: any; @@ -38,10 +39,10 @@ export class DSpaceRESTv2Service { */ get(absoluteURL: string): Observable { return this.http.get(absoluteURL, { observe: 'response' }).pipe( - map((res: HttpResponse) => ({ payload: res.body, statusCode: res.statusText })), + 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}); })); } @@ -60,6 +61,9 @@ export class DSpaceRESTv2Service { request(method: RestRequestMethod, url: string, body?: any, options?: HttpOptions): Observable { const requestOptions: HttpOptions = {}; requestOptions.body = body; + if (method === RestRequestMethod.POST && isNotEmpty(body) && isNotEmpty(body.name)) { + requestOptions.body = this.buildFormData(body); + } requestOptions.observe = 'response'; if (options && options.headers) { requestOptions.headers = Object.assign(new HttpHeaders(), options.headers); @@ -68,11 +72,32 @@ export class DSpaceRESTv2Service { requestOptions.responseType = options.responseType; } 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..32286929ee 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -1,22 +1,54 @@ +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.actions.ts b/src/app/core/index/index.actions.ts index 014b6561a3..98d07d59d5 100644 --- a/src/app/core/index/index.actions.ts +++ b/src/app/core/index/index.actions.ts @@ -8,7 +8,8 @@ import { IndexName } from './index.reducer'; */ export const IndexActionTypes = { ADD: type('dspace/core/index/ADD'), - REMOVE_BY_VALUE: type('dspace/core/index/REMOVE_BY_VALUE') + REMOVE_BY_VALUE: type('dspace/core/index/REMOVE_BY_VALUE'), + REMOVE_BY_SUBSTRING: type('dspace/core/index/REMOVE_BY_SUBSTRING') }; /* tslint:disable:max-classes-per-file */ @@ -60,6 +61,30 @@ export class RemoveFromIndexByValueAction implements Action { this.payload = { name, value }; } +} + +/** + * An ngrx action to remove multiple values from the index by substring + */ +export class RemoveFromIndexBySubstringAction implements Action { + type = IndexActionTypes.REMOVE_BY_SUBSTRING; + payload: { + name: IndexName, + value: string + }; + + /** + * Create a new RemoveFromIndexByValueAction + * + * @param name + * the name of the index to remove from + * @param value + * the value to remove the UUID for + */ + constructor(name: IndexName, value: string) { + this.payload = { name, value }; + } + } /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts index de1ba681a2..61cf313ab1 100644 --- a/src/app/core/index/index.effects.ts +++ b/src/app/core/index/index.effects.ts @@ -1,16 +1,17 @@ import { filter, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Effect, Actions, ofType } from '@ngrx/effects'; +import { Actions, Effect, ofType } from '@ngrx/effects'; import { - ObjectCacheActionTypes, AddToObjectCacheAction, + AddToObjectCacheAction, + ObjectCacheActionTypes, RemoveFromObjectCacheAction } from '../cache/object-cache.actions'; import { RequestActionTypes, RequestConfigureAction } from '../data/request.actions'; -import { RestRequestMethod } from '../data/request.models'; import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions'; import { hasValue } from '../../shared/empty.util'; import { IndexName } from './index.reducer'; +import { RestRequestMethod } from '../data/rest-request-method'; @Injectable() export class UUIDIndexEffects { @@ -42,7 +43,7 @@ export class UUIDIndexEffects { @Effect() addRequest$ = this.actions$ .pipe( ofType(RequestActionTypes.CONFIGURE), - filter((action: RequestConfigureAction) => action.payload.method === RestRequestMethod.Get), + filter((action: RequestConfigureAction) => action.payload.method === RestRequestMethod.GET), map((action: RequestConfigureAction) => { return new AddToIndexAction( IndexName.REQUEST, @@ -52,17 +53,6 @@ export class UUIDIndexEffects { }) ); - // @Effect() removeRequest$ = this.actions$ - // .pipe( - // ofType(ObjectCacheActionTypes.REMOVE), - // map((action: RemoveFromObjectCacheAction) => { - // return new RemoveFromIndexByValueAction( - // IndexName.OBJECT, - // action.payload - // ); - // }) - // ) - constructor(private actions$: Actions) { } diff --git a/src/app/core/index/index.reducer.spec.ts b/src/app/core/index/index.reducer.spec.ts index a1cf92aeb3..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,9 +17,13 @@ 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]: { + [key1]: val1 + },[IndexName.UUID_MAPPING]: { + [key1]: val1 } }; deepFreeze(testState); @@ -55,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 869dee9e51..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, + 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', - REQUEST = 'get-request/href-to-uuid' + + // 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' } +/** + * The state of a single index + */ export interface IndexState { - // TODO this should be `[name in IndexName]: {` but that's currently broken, - // see https://github.com/Microsoft/TypeScript/issues/13042 - [name: string]: { - [key: string]: string - } + [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: { @@ -32,23 +62,48 @@ export function indexReducer(state = initialState, action: IndexAction): IndexSt return removeFromIndexByValue(state, action as RemoveFromIndexByValueAction) } + case IndexActionTypes.REMOVE_BY_SUBSTRING: { + return removeFromIndexBySubstring(state, action as RemoveFromIndexBySubstringAction) + } + default: { return state; } } } -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 }); - return Object.assign({}, state, { + const obs = Object.assign({}, state, { [action.payload.name]: newSubState - }) + }); + 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) { @@ -60,3 +115,26 @@ function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValu [action.payload.name]: newSubState }); } + +/** + * 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) { + if (value.indexOf(action.payload.value) < 0) { + newSubState[value] = subState[value]; + } + } + return Object.assign({}, state, { + [action.payload.name]: newSubState + }); +} 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 cb2595adc4..f0a1759be6 100644 --- a/src/app/core/integration/authority.service.ts +++ b/src/app/core/integration/authority.service.ts @@ -1,19 +1,21 @@ import { Injectable } from '@angular/core'; -import { ResponseCacheService } from '../cache/response-cache.service'; 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 responseCache: ResponseCacheService, 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 9c3e5b0344..4187606265 100644 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ b/src/app/core/integration/integration-response-parsing.service.spec.ts @@ -1,4 +1,4 @@ -import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; @@ -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 06c6b9620d..2d3693cf3d 100644 --- a/src/app/core/integration/integration-response-parsing.service.ts +++ b/src/app/core/integration/integration-response-parsing.service.ts @@ -6,7 +6,7 @@ import { ErrorResponse, IntegrationSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; import { IntegrationObjectFactory } from './integration-object-factory'; @@ -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, @@ -32,16 +34,27 @@ 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.href); - return new IntegrationSuccessResponse(dataDefinition, data.statusCode, this.processPageInfo(data.payload.page)); + const dataDefinition = this.process(data.payload, request.uuid); + 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 f7e3769620..02fff950ed 100644 --- a/src/app/core/integration/integration.service.spec.ts +++ b/src/app/core/integration/integration.service.spec.ts @@ -1,7 +1,6 @@ import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { IntegrationRequest } from '../data/request.models'; @@ -9,17 +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 responseCache: ResponseCacheService, protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService) { super(); } @@ -28,40 +31,34 @@ class TestService extends IntegrationService { describe('IntegrationService', () => { let scheduler: TestScheduler; let service: TestService; - let responseCache: ResponseCacheService; 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 initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: {response: {isSuccessful}} - }) - }); - } - function initTestService(): TestService { return new TestService( - responseCache, requestService, + rdbService, halService ); } beforeEach(() => { - responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(integrationEndpoint); findOptions = new IntegrationSearchOptions(uuid, name, metadata, query); @@ -80,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 3c71ca5f3b..5826f4646d 100644 --- a/src/app/core/integration/integration.service.ts +++ b/src/app/core/integration/integration.service.ts @@ -1,30 +1,31 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { IntegrationSuccessResponse } from '../cache/response-cache.models'; +import { IntegrationSuccessResponse } from '../cache/response.models'; import { GetRequest, IntegrationRequest } from '../data/request.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; 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 { getResponseFromEntry } from '../shared/operators'; export abstract class IntegrationService { protected request: IntegrationRequest; - protected abstract responseCache: ResponseCacheService; 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.responseCache.get(request.href).pipe( - map((entry: ResponseCacheEntry) => entry.response), - mergeMap((response) => { + return this.requestService.getByHref(request.href).pipe( + getResponseFromEntry(), + 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`)); } @@ -33,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; } @@ -74,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 2456ae9e55..cfb5a0751d 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -23,7 +23,6 @@ import { ItemDataService } from '../data/item-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; @@ -32,7 +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 { 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({ @@ -63,11 +68,11 @@ describe('MetadataService', () => { let store: Store; let objectCacheService: ObjectCacheService; - let responseCacheService: ResponseCacheService; let requestService: RequestService; let uuidService: UUIDService; let remoteDataBuildService: RemoteDataBuildService; let itemDataService: ItemDataService; + let authService: AuthService; let location: Location; let router: Router; @@ -83,10 +88,9 @@ describe('MetadataService', () => { spyOn(store, 'dispatch'); objectCacheService = new ObjectCacheService(store); - responseCacheService = new ResponseCacheService(store); uuidService = new UUIDService(); - requestService = new RequestService(objectCacheService, responseCacheService, uuidService, store); - remoteDataBuildService = new RemoteDataBuildService(objectCacheService, responseCacheService, requestService); + requestService = new RequestService(objectCacheService, uuidService, store, undefined); + remoteDataBuildService = new RemoteDataBuildService(objectCacheService, requestService); TestBed.configureTestingModule({ imports: [ @@ -109,11 +113,15 @@ describe('MetadataService', () => { ], providers: [ { provide: ObjectCacheService, useValue: objectCacheService }, - { provide: ResponseCacheService, useValue: responseCacheService }, { 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, @@ -126,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); @@ -144,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'); @@ -171,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); @@ -204,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 3a63be3f55..a95fc73d33 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -1,4 +1,12 @@ -import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'rxjs/operators'; +import { + catchError, + distinctUntilKeyChanged, + filter, + find, + first, + map, + take +} from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; @@ -59,7 +67,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); @@ -73,7 +81,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); }); @@ -263,13 +271,17 @@ export class MetadataService { .pipe( first((files) => isNotEmpty(files)), catchError((error) => { - console.debug(error); + console.debug(error.message); return [] })) .subscribe((bitstreams: Bitstream[]) => { for (const bitstream of bitstreams) { bitstream.format.pipe( first(), + catchError((error: Error) => { + console.debug(error.message); + return [] + }), map((rd: RemoteData) => rd.payload), filter((format: BitstreamFormat) => hasValue(format))) .subscribe((format: BitstreamFormat) => { @@ -282,6 +294,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 * @@ -289,14 +305,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'); } /** @@ -306,40 +315,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 { @@ -347,15 +331,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 d0ed1e5cb8..8274ceef60 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -1,29 +1,46 @@ import { TestBed } from '@angular/core/testing'; import { RegistryService } from './registry.service'; import { CommonModule } from '@angular/common'; -import { ResponseCacheService } from '../cache/response-cache.service'; 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 { ResponseCacheEntry } from '../cache/response-cache.reducer'; +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'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse -} from '../cache/response-cache.models'; + 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 { @@ -31,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 @@ -52,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; @@ -121,16 +109,17 @@ 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) }; const rdbStub = { - toRemoteDataObservable: (requestEntryObs: Observable, responseCacheObs: Observable, payloadObs: Observable) => { + toRemoteDataObservable: (requestEntryObs: Observable, payloadObs: Observable) => { return observableCombineLatest(requestEntryObs, - responseCacheObs, payloadObs).pipe(map(([req, res, pay]) => { - return { req, res, pay }; + payloadObs).pipe(map(([req, pay]) => { + return { req, pay }; }) ); }, @@ -141,20 +130,21 @@ describe('RegistryService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [CommonModule], + imports: [CommonModule, StoreModule.forRoot({}), TranslateModule.forRoot()], declarations: [ DummyComponent ], providers: [ - { provide: ResponseCacheService, useValue: getMockResponseCacheService() }, { 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)); }); @@ -163,11 +153,11 @@ describe('RegistryService', () => { metadataschemas: mockSchemasList, page: pageInfo }); - const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(observableOf(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getMetadataSchemas(pagination).subscribe((value) => { }); @@ -185,10 +175,6 @@ describe('RegistryService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); }); - - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams); - }); }); describe('when requesting metadataschema by name', () => { @@ -196,11 +182,11 @@ describe('RegistryService', () => { metadataschemas: mockSchemasList, page: pageInfo }); - const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(observableOf(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getMetadataSchemaByName(mockSchemasList[0].prefix).subscribe((value) => { }); @@ -218,10 +204,6 @@ describe('RegistryService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((registryService as any).requestService.getByHref.calls.argsFor(0)[0]).toContain(endpoint); }); - - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get.calls.argsFor(0)[0]).toContain(endpoint); - }); }); describe('when requesting metadatafields', () => { @@ -229,11 +211,11 @@ describe('RegistryService', () => { metadatafields: mockFieldsList, page: pageInfo }); - const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, 200, 'OK', pageInfo); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(observableOf(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getMetadataFieldsBySchema(mockSchemasList[0], pagination).subscribe((value) => { }); @@ -249,11 +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); - }); - - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams); + expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(fieldEndpointWithParams); }); }); @@ -262,11 +240,11 @@ describe('RegistryService', () => { bitstreamformats: mockFieldsList, page: pageInfo }); - const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, 200, 'OK', pageInfo); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(observableOf(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getBitstreamFormats(pagination).subscribe((value) => { }); @@ -284,9 +262,187 @@ describe('RegistryService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); }); + }); - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get).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 7e7c18f69e..137b4c3a87 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -6,29 +6,70 @@ 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 { 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'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { + ErrorResponse, MetadatafieldSuccessResponse, MetadataschemaSuccessResponse, RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse -} from '../cache/response-cache.models'; + 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 { 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 { + 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 { @@ -37,10 +78,12 @@ export class RegistryService { private metadataFieldsPath = 'metadatafields'; private bitstreamFormatsPath = 'bitstreamformats'; - constructor(protected responseCache: ResponseCacheService, - protected requestService: RequestService, + constructor(protected requestService: RequestService, private rdb: RemoteDataBuildService, - private halService: HALEndpointService) { + private halService: HALEndpointService, + private store: Store, + private notificationsService: NotificationsService, + private translateService: TranslateService) { } @@ -51,12 +94,8 @@ export class RegistryService { flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rmrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) ); @@ -64,8 +103,8 @@ export class RegistryService { map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadataschemasSuccessResponse) => response.pageInfo) ); @@ -75,7 +114,7 @@ export class RegistryService { }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } public getMetadataSchemaByName(schemaName: string): Observable> { @@ -90,12 +129,8 @@ export class RegistryService { flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rmrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) ); @@ -104,32 +139,28 @@ export class RegistryService { map((metadataSchemas: MetadataSchema[]) => metadataSchemas.filter((value) => value.prefix === schemaName)[0]) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, metadataschemaObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, metadataschemaObs); } public getMetadataFieldsBySchema(schema: MetadataSchema, pagination: PaginationComponentOptions): Observable>> { - const requestObs = this.getMetadataFieldsRequestObs(pagination); + const requestObs = this.getMetadataFieldsBySchemaRequestObs(pagination, schema); const requestEntryObs = requestObs.pipe( flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rmrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse) ); const metadatafieldsObs: Observable = rmrObs.pipe( - map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields), - map((metadataFields: MetadataField[]) => metadataFields.filter((field) => field.schema.id === schema.id)) + map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo) ); @@ -139,7 +170,49 @@ export class RegistryService { }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + 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( + 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), + /* 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( + 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); } public getBitstreamFormats(pagination: PaginationComponentOptions): Observable>> { @@ -149,12 +222,8 @@ export class RegistryService { flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rbrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rbrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryBitstreamformatsSuccessResponse) => response.bitstreamformatsResponse) ); @@ -162,8 +231,8 @@ export class RegistryService { map((rbr: RegistryBitstreamformatsResponse) => rbr.bitstreamformats) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryBitstreamformatsSuccessResponse) => response.pageInfo) ); @@ -173,10 +242,10 @@ export class RegistryService { }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + 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[] = []; @@ -196,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) => { @@ -236,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/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 893a7e0b94..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'); } /** @@ -61,6 +61,6 @@ export class Community extends DSpaceObject { collections: Observable>>; - subcommunities: Observable>>; + subcommunities: Observable>>; } 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 3a40d142aa..71c6ee7837 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,29 +1,30 @@ -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'; /** * An abstract model class for a DSpaceObject. */ -export class DSpaceObject implements CacheableObject, ListableObject { +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 +35,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,41 +69,58 @@ 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); } } 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/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index 0c2afe938b..8b3011e7d7 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -1,44 +1,66 @@ -import { cold, hot } from 'jasmine-marbles'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { GlobalConfig } from '../../../config/global-config.interface'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from './hal-endpoint.service'; import { EndpointMapRequest } from '../data/request.models'; +import { RequestEntry } from '../data/request.reducer'; +import { of as observableOf } from 'rxjs'; describe('HALEndpointService', () => { let service: HALEndpointService; - let responseCache: ResponseCacheService; let requestService: RequestService; let envConfig: GlobalConfig; + let requestEntry; const endpointMap = { test: 'https://rest.api/test', + foo: 'https://rest.api/foo', + bar: 'https://rest.api/bar', + endpoint: 'https://rest.api/endpoint', + link: 'https://rest.api/link', + another: 'https://rest.api/another', + }; + const start = 'http://start.com'; + const one = 'http://one.com'; + const two = 'http://two.com'; + const endpointMaps = { + [start]: { + one: one, + two: 'empty', + endpoint: 'https://rest.api/endpoint', + link: 'https://rest.api/link', + another: 'https://rest.api/another', + }, + [one]: { + one: 'empty', + two: two, + bar: 'https://rest.api/bar', + } }; const linkPath = 'test'; + beforeEach(() => { + requestEntry = { + request: { responseMsToLive: 1000 } as any, + requestPending: false, + responsePending: false, + completed: true, + response: { endpointMap: endpointMap } as any + } as RequestEntry; + requestService = getMockRequestService(observableOf(requestEntry)); + + envConfig = { + rest: { baseUrl: 'https://rest.api/' } + } as any; + + service = new HALEndpointService( + requestService, + envConfig + ); + }); + describe('getRootEndpointMap', () => { - beforeEach(() => { - responseCache = jasmine.createSpyObj('responseCache', { - get: hot('a-', { - a: { - response: { endpointMap: endpointMap } - } - }) - }); - - requestService = getMockRequestService(); - - envConfig = { - rest: { baseUrl: 'https://rest.api/' } - } as any; - - service = new HALEndpointService( - responseCache, - requestService, - envConfig - ); - }); it('should configure a new EndpointMapRequest', () => { (service as any).getRootEndpointMap(); @@ -48,8 +70,8 @@ describe('HALEndpointService', () => { it('should return an Observable of the endpoint map', () => { const result = (service as any).getRootEndpointMap(); - const expected = cold('b-', { b: endpointMap }); - expect(result).toBeObservable(expected); + const expected = '(b|)'; + getTestScheduler().expectObservable(result).toBe(expected, { b: endpointMap }); }); }); @@ -60,12 +82,6 @@ describe('HALEndpointService', () => { envConfig = { rest: { baseUrl: 'https://rest.api/' } } as any; - - service = new HALEndpointService( - responseCache, - requestService, - envConfig - ); }); it('should return the endpoint URL for the service\'s linkPath', () => { @@ -86,10 +102,53 @@ describe('HALEndpointService', () => { }); }); + describe('getEndpointAt', () => { + it('should throw an error when the list of hal endpoint names is empty', () => { + const endpointAtWithoutEndpointNames = () => { + (service as any).getEndpointAt('') + }; + expect(endpointAtWithoutEndpointNames).toThrow(); + }); + + it('should be at least called as many times as the length of halNames', () => { + spyOn(service as any, 'getEndpointMapAt').and.returnValue(observableOf(endpointMap)); + spyOn((service as any), 'getEndpointAt').and.callThrough(); + + (service as any).getEndpointAt('', 'endpoint').subscribe(); + + expect((service as any).getEndpointAt.calls.count()).toEqual(1); + + (service as any).getEndpointAt.calls.reset(); + + (service as any).getEndpointAt('', 'endpoint', 'another').subscribe(); + + expect((service as any).getEndpointAt.calls.count()).toBeGreaterThanOrEqual(2); + + (service as any).getEndpointAt.calls.reset(); + + (service as any).getEndpointAt('', 'endpoint', 'another', 'foo', 'bar', 'test').subscribe(); + + expect((service as any).getEndpointAt.calls.count()).toBeGreaterThanOrEqual(5); + }); + + it('should return the correct endpoint', () => { + spyOn(service as any, 'getEndpointMapAt').and.callFake((param) => { + return observableOf(endpointMaps[param]); + }); + + (service as any).getEndpointAt(start, 'one').subscribe((endpoint) => { + expect(endpoint).toEqual(one); + }); + + (service as any).getEndpointAt(start, 'one', 'two').subscribe((endpoint) => { + expect(endpoint).toEqual(two); + }); + }); + }); + describe('isEnabledOnRestApi', () => { beforeEach(() => { service = new HALEndpointService( - responseCache, requestService, envConfig ); diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index d72b9e9a9f..a93d54db64 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -1,21 +1,27 @@ -import {of as observableOf, Observable } from 'rxjs'; -import {filter, distinctUntilChanged, map, flatMap, startWith, tap } from 'rxjs/operators'; +import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { + distinctUntilChanged, first, + map, + mergeMap, + startWith, + switchMap, + tap +} from 'rxjs/operators'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { EndpointMap, EndpointMapSuccessResponse } from '../cache/response-cache.models'; import { EndpointMapRequest } from '../data/request.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { Inject, Injectable } from '@angular/core'; import { GLOBAL_CONFIG } from '../../../config'; +import { EndpointMap, EndpointMapSuccessResponse } from '../cache/response.models'; +import { getResponseFromEntry } from './operators'; +import { URLCombiner } from '../url-combiner/url-combiner'; @Injectable() export class HALEndpointService { - constructor(private responseCache: ResponseCacheService, - private requestService: RequestService, + constructor(private requestService: RequestService, @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { } @@ -29,39 +35,48 @@ export class HALEndpointService { private getEndpointMapAt(href): Observable { const request = new EndpointMapRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); - return this.responseCache.get(request.href).pipe( - map((entry: ResponseCacheEntry) => entry.response), - filter((response: EndpointMapSuccessResponse) => isNotEmpty(response)), + return this.requestService.getByHref(request.href).pipe( + getResponseFromEntry(), map((response: EndpointMapSuccessResponse) => response.endpointMap), - distinctUntilChanged(),); + ); } public getEndpoint(linkPath: string): Observable { - return this.getEndpointAt(...linkPath.split('/')); + return this.getEndpointAt(this.getRootHref(), ...linkPath.split('/')); } - private getEndpointAt(...path: string[]): Observable { - if (isEmpty(path)) { - path = ['/']; + /** + * Resolve the actual hal url based on a list of hal names + * @param {string} href The root url to start from + * @param {string} halNames List of hal names for which a url should be resolved + * @returns {Observable} Observable that emits the found hal url + */ + private getEndpointAt(href: string, ...halNames: string[]): Observable { + if (isEmpty(halNames)) { + throw new Error('cant\'t fetch the URL without the HAL link names') + } + + const nextHref$ = this.getEndpointMapAt(href).pipe( + map((endpointMap: EndpointMap): string => { + /*TODO remove if/else block once the rest response contains _links for facets*/ + const nextName = halNames[0]; + if (hasValue(endpointMap) && hasValue(endpointMap[nextName])) { + return endpointMap[nextName]; + } else { + return new URLCombiner(href, nextName).toString(); + } + }) + ) as Observable; + + if (halNames.length === 1) { + return nextHref$; + } else { + return nextHref$.pipe( + switchMap((nextHref) => this.getEndpointAt(nextHref, ...halNames.slice(1))) + ); } - let currentPath; - const pipeArguments = path - .map((subPath: string, index: number) => [ - flatMap((href: string) => this.getEndpointMapAt(href)), - map((endpointMap: EndpointMap) => { - if (hasValue(endpointMap) && hasValue(endpointMap[subPath])) { - currentPath = endpointMap[subPath]; - return endpointMap[subPath]; - } else { - /*TODO remove if/else block once the rest response contains _links for facets*/ - currentPath += '/' + subPath; - return currentPath; - } - }), - ]) - .reduce((combined, thisElement) => [...combined, ...thisElement], []); - return observableOf(this.getRootHref()).pipe(...pipeArguments, distinctUntilChanged()); } public isEnabledOnRestApi(linkPath: string): Observable { diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 69def7b969..6cd5634fd0 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,4 +1,4 @@ -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'; @@ -90,14 +90,16 @@ export class Item extends DSpaceObject { */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams.pipe( + filter((rd: RemoteData>) => !rd.isResponsePending), 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.models.ts b/src/app/core/shared/metadata.models.ts new file mode 100644 index 0000000000..ab007c15f6 --- /dev/null +++ b/src/app/core/shared/metadata.models.ts @@ -0,0 +1,92 @@ +import * as uuidv4 from 'uuid/v4'; +import { autoserialize, Serialize, Deserialize } from 'cerialize'; +/* tslint:disable:max-classes-per-file */ + +/** 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; +} + +/** 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 order. */ + order: 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..7fbea14b13 --- /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 { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language }; +}; + +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..938d646a82 --- /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: MetadataValue) => { + const val = Object.assign({}, 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 16bf633705..2eb47507b2 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -1,104 +1,113 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { GetRequest, RestRequest } from '../data/request.models'; +import { GetRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { configureRequest, - filterSuccessfulResponses, getRemoteDataPayload, - getRequestFromSelflink, getResourceLinksFromResponse, - getResponseFromSelflink + filterSuccessfulResponses, + getAllSucceededRemoteData, + getRemoteDataPayload, + getRequestFromRequestHref, + getRequestFromRequestUUID, + getResourceLinksFromResponse, + getResponseFromEntry, + getSucceededRemoteData } from './operators'; +import { RemoteData } from '../data/remote-data'; describe('Core Module - RxJS Operators', () => { let scheduler: TestScheduler; let requestService: RequestService; - const testSelfLink = 'https://rest.api/'; + const testRequestHref = 'https://rest.api/'; + const testRequestUUID = 'https://rest.api/'; const testRCEs = { a: { response: { isSuccessful: true, resourceSelfLinks: ['a', 'b', 'c', 'd'] } }, b: { response: { isSuccessful: false, resourceSelfLinks: ['e', 'f'] } }, c: { response: { isSuccessful: undefined, resourceSelfLinks: ['g', 'h', 'i'] } }, d: { response: { isSuccessful: true, resourceSelfLinks: ['j', 'k', 'l', 'm', 'n'] } }, - e: { response: { isSuccessful: 1, resourceSelfLinks: [] } } + e: { response: { isSuccessful: 1, resourceSelfLinks: [] } }, + f: { response: undefined }, + g: undefined + }; + + const testResponses = { + a: testRCEs.a.response, + b: testRCEs.b.response, + c: testRCEs.c.response, + d: testRCEs.d.response, + e: testRCEs.e.response }; beforeEach(() => { scheduler = getTestScheduler(); }); - describe('getRequestFromSelflink', () => { + describe('getRequestFromRequestHref', () => { it('should return the RequestEntry corresponding to the self link in the source', () => { requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getRequestFromSelflink(requestService)); - const expected = cold('a', { a: new RequestEntry()}); + const source = hot('a', { a: testRequestHref }); + const result = source.pipe(getRequestFromRequestHref(requestService)); + const expected = cold('a', { a: new RequestEntry() }); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); it('should use the requestService to fetch the request by its self link', () => { requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); - scheduler.schedule(() => source.pipe(getRequestFromSelflink(requestService)).subscribe()); + const source = hot('a', { a: testRequestHref }); + scheduler.schedule(() => source.pipe(getRequestFromRequestHref(requestService)).subscribe()); scheduler.flush(); - expect(requestService.getByHref).toHaveBeenCalledWith(testSelfLink) + expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref); }); it('shouldn\'t return anything if there is no request matching the self link', () => { requestService = getMockRequestService(cold('a', { a: undefined })); - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getRequestFromSelflink(requestService)); + const source = hot('a', { a: testRequestUUID }); + const result = source.pipe(getRequestFromRequestHref(requestService)); const expected = cold('-'); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); - describe('getResponseFromSelflink', () => { - let responseCacheService: ResponseCacheService; + describe('getRequestFromRequestUUID', () => { - beforeEach(() => { - scheduler = getTestScheduler(); + it('should return the RequestEntry corresponding to the request uuid in the source', () => { + requestService = getMockRequestService(); + + const source = hot('a', { a: testRequestUUID }); + const result = source.pipe(getRequestFromRequestUUID(requestService)); + const expected = cold('a', { a: new RequestEntry() }); + + expect(result).toBeObservable(expected); }); - it('should return the ResponseCacheEntry corresponding to the self link in the source', () => { - responseCacheService = getMockResponseCacheService(); + it('should use the requestService to fetch the request by its request uuid', () => { + requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getResponseFromSelflink(responseCacheService)); - const expected = cold('a', { a: new ResponseCacheEntry()}); - - expect(result).toBeObservable(expected) - }); - - it('should use the responseCacheService to fetch the response by the request\'s link', () => { - responseCacheService = getMockResponseCacheService(); - - const source = hot('a', { a: testSelfLink }); - scheduler.schedule(() => source.pipe(getResponseFromSelflink(responseCacheService)).subscribe()); + const source = hot('a', { a: testRequestUUID }); + scheduler.schedule(() => source.pipe(getRequestFromRequestUUID(requestService)).subscribe()); scheduler.flush(); - expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink) + expect(requestService.getByUUID).toHaveBeenCalledWith(testRequestUUID) }); - it('shouldn\'t return anything if there is no response matching the request\'s link', () => { - responseCacheService = getMockResponseCacheService(undefined, cold('a', { a: undefined })); + it('shouldn\'t return anything if there is no request matching the request uuid', () => { + requestService = getMockRequestService(cold('a', { a: undefined })); - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getResponseFromSelflink(responseCacheService)); + const source = hot('a', { a: testRequestUUID }); + const result = source.pipe(getRequestFromRequestUUID(requestService)); const expected = cold('-'); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -106,9 +115,9 @@ describe('Core Module - RxJS Operators', () => { it('should only return responses for which isSuccessful === true', () => { const source = hot('abcde', testRCEs); const result = source.pipe(filterSuccessfulResponses()); - const expected = cold('a--d-', testRCEs); + const expected = cold('a--d-', testResponses); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -121,19 +130,19 @@ describe('Core Module - RxJS Operators', () => { d: testRCEs.d.response.resourceSelfLinks }); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); describe('configureRequest', () => { it('should call requestService.configure with the source request', () => { requestService = getMockRequestService(); - const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testSelfLink); + const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testRequestHref); const source = hot('a', { a: testRequest }); scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(testRequest) + expect(requestService.configure).toHaveBeenCalledWith(testRequest, undefined); }); }); @@ -146,7 +155,76 @@ describe('Core Module - RxJS Operators', () => { a: testRD.a.payload, }); + expect(result).toBeObservable(expected); + }); + }); + + 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('getSucceededRemoteData', () => { + it('should return the first() hasSucceeded RemoteData Observable', () => { + const testRD = { + a: new RemoteData(false, false, true, null, undefined), + b: new RemoteData(false, false, false, null, 'b'), + c: new RemoteData(false, false, undefined, null, 'c'), + d: new RemoteData(false, false, true, null, 'd'), + e: new RemoteData(false, false, true, null, 'e'), + }; + const source = hot('abcde', testRD); + const result = source.pipe(getSucceededRemoteData()); + + result.subscribe((value) => expect(value) + .toEqual(new RemoteData(false, false, true, null, 'd'))); + + }); + }); + + 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 = { + a: new RemoteData(false, false, true, null, undefined), + b: new RemoteData(false, false, false, null, 'b'), + c: new RemoteData(false, false, undefined, null, 'c'), + d: new RemoteData(false, false, true, null, 'd'), + e: new RemoteData(false, false, true, null, 'e'), + }; + const source = hot('abcde', testRD); + const result = source.pipe(getAllSucceededRemoteData()); + const expected = cold('---de', testRD); + + expect(result).toBeObservable(expected); + + }); + + }); }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 9622bb91e1..ce9740a0fc 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,9 +1,7 @@ import { Observable } from 'rxjs'; -import { filter, first, flatMap, map, tap } from 'rxjs/operators'; -import { hasValueOperator, isNotEmpty } from '../../shared/empty.util'; -import { DSOSuccessResponse } from '../cache/response-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; +import { filter, find, flatMap, map, 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'; import { RestRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; @@ -17,34 +15,44 @@ import { SearchResult } from '../../+search-page/search-result.model'; * This file contains custom RxJS operators that can be used in multiple places */ -export const getRequestFromSelflink = (requestService: RequestService) => +export const getRequestFromRequestHref = (requestService: RequestService) => (source: Observable): Observable => source.pipe( flatMap((href: string) => requestService.getByHref(href)), hasValueOperator() ); -export const getResponseFromSelflink = (responseCache: ResponseCacheService) => - (source: Observable): Observable => +export const getRequestFromRequestUUID = (requestService: RequestService) => + (source: Observable): Observable => source.pipe( - flatMap((href: string) => responseCache.get(href)), + flatMap((uuid: string) => requestService.getByUUID(uuid)), hasValueOperator() ); export const filterSuccessfulResponses = () => - (source: Observable): Observable => - source.pipe(filter((entry: ResponseCacheEntry) => entry.response.isSuccessful === true)); - -export const getResourceLinksFromResponse = () => - (source: Observable): Observable => + (source: Observable): Observable => source.pipe( - filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks), + getResponseFromEntry(), + filter((response: RestResponse) => response.isSuccessful === true), ); -export const configureRequest = (requestService: RequestService) => +export const getResponseFromEntry = () => + (source: Observable): Observable => + source.pipe( + filter((entry: RequestEntry) => hasValue(entry) && hasValue(entry.response)), + map((entry: RequestEntry) => entry.response) + ); + +export const getResourceLinksFromResponse = () => + (source: Observable): Observable => + source.pipe( + filterSuccessfulResponses(), + map((response: DSOSuccessResponse) => response.resourceSelfLinks), + ); + +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 => @@ -52,15 +60,24 @@ export const getRemoteDataPayload = () => export const getSucceededRemoteData = () => (source: Observable>): Observable> => - source.pipe(first((rd: RemoteData) => rd.hasSucceeded)); + source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); + +export const getFinishedRemoteData = () => + (source: Observable>): Observable> => + source.pipe(find((rd: RemoteData) => !rd.isLoading)); + +export const getAllSucceededRemoteData = () => + (source: Observable>): Observable> => + source.pipe(filter((rd: RemoteData) => rd.hasSucceeded)); export const toDSpaceObjectListRD = () => (source: Observable>>>): Observable>> => source.pipe( + filter((rd: RemoteData>>) => rd.hasSucceeded), map((rd: RemoteData>>) => { const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.dspaceObject); - const payload = Object.assign(rd.payload, { page: dsoPage }) as any; - return Object.assign(rd, {payload: payload}); + const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList; + return Object.assign(rd, { payload: payload }); }) ); @@ -74,7 +91,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)) { @@ -84,3 +101,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..484f1ea6e2 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -8,5 +8,16 @@ export enum ResourceType { Community = 'community', EPerson = 'eperson', Group = 'group', - ResourcePolicy = 'resourcePolicy' + ResourcePolicy = 'resourcePolicy', + MetadataSchema = 'metadataschema', + MetadataField = 'metadatafield', + License = 'license', + Workflowitem = 'workflowitem', + Workspaceitem = 'workspaceitem', + SubmissionDefinitions = 'submissiondefinitions', + SubmissionDefinition = 'submissiondefinition', + SubmissionForm = 'submissionform', + SubmissionForms = 'submissionforms', + SubmissionSections = 'submissionsections', + SubmissionSection = 'submissionsection', } 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/submission/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts new file mode 100644 index 0000000000..8091781760 --- /dev/null +++ b/src/app/core/submission/models/normalized-submission-object.model.ts @@ -0,0 +1,37 @@ +import { autoserialize, 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 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..6b2d9a03b9 --- /dev/null +++ b/src/app/core/submission/models/submission-object.model.ts @@ -0,0 +1,62 @@ +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 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..20dfb43cbd --- /dev/null +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -0,0 +1,167 @@ +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') + && obj.hasOwnProperty('place')) +} + +/** + * Export a function to normalize sections object of the server response + * + * @param obj + */ +export function normalizeSectionData(obj: any) { + 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, + obj.confidence, + obj.otherInformation + ); + } else if (Array.isArray(obj)) { + result = []; + obj.forEach((item, index) => { + result[index] = normalizeSectionData(item); + }); + } 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) + && (data.statusCode === 201 || data.statusCode === 200)) { + 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) && data.statusCode === 204) { + // Response from a DELETE request + 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) => { + // Parse value and normalize it + const normValue = normalizeSectionData(valueItem); + 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/header-nav-wrapper/header-navbar-wrapper.component.html b/src/app/header-nav-wrapper/header-navbar-wrapper.component.html new file mode 100644 index 0000000000..931ab36749 --- /dev/null +++ b/src/app/header-nav-wrapper/header-navbar-wrapper.component.html @@ -0,0 +1,4 @@ +
    + + +
    \ No newline at end of file diff --git a/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss b/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss new file mode 100644 index 0000000000..f514508385 --- /dev/null +++ b/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss @@ -0,0 +1,9 @@ +@import '../../styles/variables.scss'; + +@media screen and (max-width: map-get($grid-breakpoints, md)) { + :host.open { + background-color: $white; + top: 0; + position: sticky; + } +} \ No newline at end of file diff --git a/src/app/header-nav-wrapper/header-navbar-wrapper.component.ts b/src/app/header-nav-wrapper/header-navbar-wrapper.component.ts new file mode 100644 index 0000000000..34692184ff --- /dev/null +++ b/src/app/header-nav-wrapper/header-navbar-wrapper.component.ts @@ -0,0 +1,40 @@ +import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '../app.reducer'; +import { hasValue } from '../shared/empty.util'; +import { Observable } from 'rxjs/internal/Observable'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuID } from '../shared/menu/initial-menus-state'; + +/** + * This component represents a wrapper for the horizontal navbar and the header + */ +@Component({ + selector: 'ds-header-navbar-wrapper', + styleUrls: ['header-navbar-wrapper.component.scss'], + templateUrl: 'header-navbar-wrapper.component.html', +}) +export class HeaderNavbarWrapperComponent implements OnInit, OnDestroy { + @HostBinding('class.open') isOpen = false; + private sub: Subscription; + public isNavBarCollapsed: Observable; + menuID = MenuID.PUBLIC; + + constructor( + private store: Store, + private menuService: MenuService + ) { + } + + ngOnInit(): void { + this.isNavBarCollapsed = this.menuService.isMenuCollapsed(this.menuID); + this.sub = this.isNavBarCollapsed.subscribe((isCollapsed) => this.isOpen = !isCollapsed) + } + + ngOnDestroy() { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/header/header.actions.ts b/src/app/header/header.actions.ts deleted file mode 100644 index 7176474a4a..0000000000 --- a/src/app/header/header.actions.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 HeaderActionTypes = { - COLLAPSE: type('dspace/header/COLLAPSE'), - EXPAND: type('dspace/header/EXPAND'), - TOGGLE: type('dspace/header/TOGGLE') -}; - -/* tslint:disable:max-classes-per-file */ -export class HeaderCollapseAction implements Action { - type = HeaderActionTypes.COLLAPSE; -} - -export class HeaderExpandAction implements Action { - type = HeaderActionTypes.EXPAND; -} - -export class HeaderToggleAction implements Action { - type = HeaderActionTypes.TOGGLE; -} -/* 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 HeaderAction - = HeaderCollapseAction - | HeaderExpandAction - | HeaderToggleAction diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index f47696609c..402eb7a44d 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -1,18 +1,20 @@
    -
    diff --git a/src/app/header/header.component.scss b/src/app/header/header.component.scss index 81f6a809bf..df4c0b8fb8 100644 --- a/src/app/header/header.component.scss +++ b/src/app/header/header.component.scss @@ -1,14 +1,12 @@ @import '../../styles/variables.scss'; -header nav.navbar { - border-radius: 0; +.navbar-brand img { + height: $header-logo-height; + @media screen and (max-width: map-get($grid-breakpoints, sm)) { + height: $header-logo-height-xs; + } } - -header nav.navbar .navbar-toggler:hover { - cursor: pointer; -} - -header nav.navbar .navbar-toggler .navbar-toggler-icon { - background-image: none !important; - line-height: 1.5; +.navbar-toggler .navbar-toggler-icon { + background-image: none !important; + line-height: 1.5; } diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts index fd02621471..c46eef75e2 100644 --- a/src/app/header/header.component.spec.ts +++ b/src/app/header/header.component.spec.ts @@ -1,46 +1,32 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; import { of as observableOf } from 'rxjs'; import { HeaderComponent } from './header.component'; -import { HeaderState } from './header.reducer'; -import { HeaderToggleAction } from './header.actions'; -import { AuthNavMenuComponent } from '../shared/auth-nav-menu/auth-nav-menu.component'; -import { LogInComponent } from '../shared/log-in/log-in.component'; -import { LogOutComponent } from '../shared/log-out/log-out.component'; -import { LoadingComponent } from '../shared/loading/loading.component'; import { ReactiveFormsModule } from '@angular/forms'; -import { HostWindowService } from '../shared/host-window.service'; -import { HostWindowServiceStub } from '../shared/testing/host-window-service-stub'; -import { RouterStub } from '../shared/testing/router-stub'; -import { Router } from '@angular/router'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import * as ngrx from '@ngrx/store'; import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuServiceStub } from '../shared/testing/menu-service-stub'; let comp: HeaderComponent; let fixture: ComponentFixture; -let store: Store; describe('HeaderComponent', () => { + const menuService = new MenuServiceStub(); // async beforeEach beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot({}), TranslateModule.forRoot(), - NgbCollapseModule.forRoot(), NoopAnimationsModule, ReactiveFormsModule], declarations: [HeaderComponent], providers: [ - { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - { provide: Router, useClass: RouterStub }, + { provide: MenuService, useValue: menuService } ], schemas: [NO_ERRORS_SCHEMA] }) @@ -49,63 +35,25 @@ describe('HeaderComponent', () => { // synchronous beforeEach beforeEach(() => { + spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([])); + fixture = TestBed.createComponent(HeaderComponent); comp = fixture.componentInstance; - store = fixture.debugElement.injector.get(Store) as Store; - spyOn(store, 'dispatch'); }); describe('when the toggle button is clicked', () => { beforeEach(() => { + spyOn(menuService, 'toggleMenu'); const navbarToggler = fixture.debugElement.query(By.css('.navbar-toggler')); navbarToggler.triggerEventHandler('click', null); }); - it('should dispatch a HeaderToggleAction', () => { - expect(store.dispatch).toHaveBeenCalledWith(new HeaderToggleAction()); + it('should call toggleMenu on the menuService', () => { + expect(menuService.toggleMenu).toHaveBeenCalled(); }); }); - - describe('when navCollapsed in the store is true', () => { - let menu: HTMLElement; - - beforeEach(() => { - menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement; - spyOnProperty(ngrx, 'select').and.callFake(() => { - return () => { - return () => observableOf({ navCollapsed: true }) - }; - }); - fixture.detectChanges(); - }); - - it('should close the menu', () => { - expect(menu.classList).not.toContain('show'); - }); - - }); - - describe('when navCollapsed in the store is false', () => { - let menu: HTMLElement; - - beforeEach(() => { - menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement; - spyOnProperty(ngrx, 'select').and.callFake(() => { - return () => { - return () => observableOf(false) - }; - }); - fixture.detectChanges(); - }); - - it('should open the menu', () => { - expect(menu.classList).toContain('show'); - }); - - }); - }); diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index e1f8da0f9d..8f2da69f0d 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -1,43 +1,31 @@ -import { Component, OnInit } from '@angular/core'; -import { createSelector, select, Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { RouterReducerState } from '@ngrx/router-store'; - -import { HeaderState } from './header.reducer'; -import { HeaderToggleAction } from './header.actions'; -import { AppState } from '../app.reducer'; -import { HostWindowService } from '../shared/host-window.service'; - -const headerStateSelector = (state: AppState) => state.header; -const navCollapsedSelector = createSelector(headerStateSelector, (header: HeaderState) => header.navCollapsed); +import { Component } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuID } from '../shared/menu/initial-menus-state'; +/** + * Represents the header with the logo and simple navigation + */ @Component({ selector: 'ds-header', styleUrls: ['header.component.scss'], templateUrl: 'header.component.html', }) -export class HeaderComponent implements OnInit { +export class HeaderComponent { /** * Whether user is authenticated. * @type {Observable} */ public isAuthenticated: Observable; - public isNavBarCollapsed: Observable; public showAuth = false; + menuID = MenuID.PUBLIC; constructor( - private store: Store, - private windowService: HostWindowService + private menuService: MenuService ) { } - ngOnInit(): void { - // set loading - this.isNavBarCollapsed = this.store.pipe(select(navCollapsedSelector)); + public toggleNavbar(): void { + this.menuService.toggleMenu(this.menuID); } - - public toggle(): void { - this.store.dispatch(new HeaderToggleAction()); - } - } diff --git a/src/app/header/header.effects.ts b/src/app/header/header.effects.ts deleted file mode 100644 index cdc018d2d9..0000000000 --- a/src/app/header/header.effects.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { map } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { Effect, Actions, ofType } from '@ngrx/effects' -import * as fromRouter from '@ngrx/router-store'; - -import { HostWindowActionTypes } from '../shared/host-window.actions'; -import { HeaderCollapseAction } from './header.actions'; - -@Injectable() -export class HeaderEffects { - - @Effect() resize$ = this.actions$ - .pipe( - ofType(HostWindowActionTypes.RESIZE), - map(() => new HeaderCollapseAction()) - ); - - @Effect() routeChange$ = this.actions$ - .pipe( - ofType(fromRouter.ROUTER_NAVIGATION), - map(() => new HeaderCollapseAction()) - ); - - constructor(private actions$: Actions) { - - } - -} diff --git a/src/app/header/header.reducer.spec.ts b/src/app/header/header.reducer.spec.ts deleted file mode 100644 index b2630ec960..0000000000 --- a/src/app/header/header.reducer.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as deepFreeze from 'deep-freeze'; - -import { headerReducer } from './header.reducer'; -import { - HeaderCollapseAction, - HeaderExpandAction, - HeaderToggleAction -} from './header.actions'; - -class NullAction extends HeaderCollapseAction { - type = null; - - constructor() { - super(); - } -} - -describe('headerReducer', () => { - it('should return the current state when no valid actions have been made', () => { - const state = { navCollapsed: false }; - const action = new NullAction(); - const newState = headerReducer(state, action); - - expect(newState).toEqual(state); - }); - - it('should start with navCollapsed = true', () => { - const action = new NullAction(); - const initialState = headerReducer(undefined, action); - - // The navigation starts collapsed - expect(initialState.navCollapsed).toEqual(true); - }); - - it('should set navCollapsed to true in response to the COLLAPSE action', () => { - const state = { navCollapsed: false }; - const action = new HeaderCollapseAction(); - const newState = headerReducer(state, action); - - expect(newState.navCollapsed).toEqual(true); - }); - - it('should perform the COLLAPSE action without affecting the previous state', () => { - const state = { navCollapsed: false }; - deepFreeze(state); - - const action = new HeaderCollapseAction(); - headerReducer(state, action); - - // no expect required, deepFreeze will ensure an exception is thrown if the state - // is mutated, and any uncaught exception will cause the test to fail - }); - - it('should set navCollapsed to false in response to the EXPAND action', () => { - const state = { navCollapsed: true }; - const action = new HeaderExpandAction(); - const newState = headerReducer(state, action); - - expect(newState.navCollapsed).toEqual(false); - }); - - it('should perform the EXPAND action without affecting the previous state', () => { - const state = { navCollapsed: true }; - deepFreeze(state); - - const action = new HeaderExpandAction(); - headerReducer(state, action); - }); - - it('should flip the value of navCollapsed in response to the TOGGLE action', () => { - const state1 = { navCollapsed: true }; - const action = new HeaderToggleAction(); - - const state2 = headerReducer(state1, action); - const state3 = headerReducer(state2, action); - - expect(state2.navCollapsed).toEqual(false); - expect(state3.navCollapsed).toEqual(true); - }); - - it('should perform the TOGGLE action without affecting the previous state', () => { - const state = { navCollapsed: true }; - deepFreeze(state); - - const action = new HeaderToggleAction(); - headerReducer(state, action); - }); - -}); diff --git a/src/app/header/header.reducer.ts b/src/app/header/header.reducer.ts deleted file mode 100644 index 2c8a89249b..0000000000 --- a/src/app/header/header.reducer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { HeaderAction, HeaderActionTypes } from './header.actions'; - -export interface HeaderState { - navCollapsed: boolean; -} - -const initialState: HeaderState = { - navCollapsed: true -}; - -export function headerReducer(state = initialState, action: HeaderAction): HeaderState { - switch (action.type) { - - case HeaderActionTypes.COLLAPSE: { - return Object.assign({}, state, { - navCollapsed: true - }); - } - - case HeaderActionTypes.EXPAND: { - return Object.assign({}, state, { - navCollapsed: false - }); - - } - - case HeaderActionTypes.TOGGLE: { - return Object.assign({}, state, { - navCollapsed: !state.navCollapsed - }); - - } - - default: { - return state; - } - } -} diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html new file mode 100644 index 0000000000..258c8b6c11 --- /dev/null +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -0,0 +1,17 @@ +
  • \ No newline at end of file diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss new file mode 100644 index 0000000000..1fb78bef0d --- /dev/null +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss @@ -0,0 +1,27 @@ +@import '../../../styles/variables.scss'; + +.dropdown-menu { + overflow: hidden; + min-width: 100%; + border-top-left-radius: 0; + border-top-right-radius: 0; + ::ng-deep a.nav-link { + padding-right: $spacer; + padding-left: $spacer; + white-space: nowrap; + } +} + +/** Mobile menu styling **/ +@media screen and (max-width: map-get($grid-breakpoints, md)) { + .dropdown-toggle { + &:after { + float: right; + margin-top: $spacer/2; + } + } + .dropdown-menu { + border: 0; + + } +} diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts new file mode 100644 index 0000000000..d1061c72bc --- /dev/null +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts @@ -0,0 +1,176 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component'; +import { By } from '@angular/platform-browser'; +import { MenuServiceStub } from '../../shared/testing/menu-service-stub'; +import { Component } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { HostWindowService } from '../../shared/host-window.service'; +import { MenuService } from '../../shared/menu/menu.service'; +import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('ExpandableNavbarSectionComponent', () => { + let component: ExpandableNavbarSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + + describe('on larger screens', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ExpandableNavbarSectionComponent, TestComponent], + providers: [ + { provide: 'sectionDataProvider', useValue: {} }, + { provide: MenuService, useValue: menuService }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) } + ] + }).overrideComponent(ExpandableNavbarSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + + fixture = TestBed.createComponent(ExpandableNavbarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('when the mouse enters the section header', () => { + beforeEach(() => { + spyOn(menuService, 'activateSection'); + const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + sidebarToggler.triggerEventHandler('mouseenter', { + preventDefault: () => {/**/ + } + }); + }); + + it('should call activateSection on the menuService', () => { + expect(menuService.activateSection).toHaveBeenCalled(); + }); + }); + + describe('when the mouse leaves the section header', () => { + beforeEach(() => { + spyOn(menuService, 'deactivateSection'); + const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + sidebarToggler.triggerEventHandler('mouseleave', { + preventDefault: () => {/**/ + } + }); + }); + + it('should call deactivateSection on the menuService', () => { + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + }); + + describe('when a click occurs on the section header', () => { + beforeEach(() => { + spyOn(menuService, 'toggleActiveSection'); + const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')).query(By.css('a')); + sidebarToggler.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + }); + + it('should not call toggleActiveSection on the menuService', () => { + expect(menuService.toggleActiveSection).not.toHaveBeenCalled(); + }); + }); + }); + + describe('on smaller, mobile screens', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ExpandableNavbarSectionComponent, TestComponent], + providers: [ + { provide: 'sectionDataProvider', useValue: {} }, + { provide: MenuService, useValue: menuService }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(300) } + ] + }).overrideComponent(ExpandableNavbarSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + + fixture = TestBed.createComponent(ExpandableNavbarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + describe('when the mouse enters the section header', () => { + beforeEach(() => { + spyOn(menuService, 'activateSection'); + const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + sidebarToggler.triggerEventHandler('mouseenter', { + preventDefault: () => {/**/ + } + }); + }); + + it('should not call activateSection on the menuService', () => { + expect(menuService.activateSection).not.toHaveBeenCalled(); + }); + }); + + describe('when the mouse leaves the section header', () => { + beforeEach(() => { + spyOn(menuService, 'deactivateSection'); + const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + sidebarToggler.triggerEventHandler('mouseleave', { + preventDefault: () => {/**/ + } + }); + }); + + it('should not call deactivateSection on the menuService', () => { + expect(menuService.deactivateSection).not.toHaveBeenCalled(); + }); + }); + + describe('when a click occurs on the section header link', () => { + beforeEach(() => { + spyOn(menuService, 'toggleActiveSection'); + const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')).query(By.css('a')); + sidebarToggler.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + }); + + it('should call toggleActiveSection on the menuService', () => { + expect(menuService.toggleActiveSection).toHaveBeenCalled(); + }); + }); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { +} diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts new file mode 100644 index 0000000000..068854d6f8 --- /dev/null +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -0,0 +1,83 @@ +import { Component, Inject, Injector, OnInit } from '@angular/core'; +import { NavbarSectionComponent } from '../navbar-section/navbar-section.component'; +import { MenuService } from '../../shared/menu/menu.service'; +import { MenuID } from '../../shared/menu/initial-menus-state'; +import { slide } from '../../shared/animations/slide'; +import { first } from 'rxjs/operators'; +import { HostWindowService } from '../../shared/host-window.service'; +import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator'; + +/** + * Represents an expandable section in the navbar + */ +@Component({ + selector: 'ds-expandable-navbar-section', + templateUrl: './expandable-navbar-section.component.html', + styleUrls: ['./expandable-navbar-section.component.scss'], + animations: [slide] +}) +@rendersSectionForMenu(MenuID.PUBLIC, true) +export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit { + /** + * This section resides in the Public Navbar + */ + menuID = MenuID.PUBLIC; + + constructor(@Inject('sectionDataProvider') menuSection, + protected menuService: MenuService, + protected injector: Injector, + private windowService: HostWindowService + ) { + super(menuSection, menuService, injector); + } + + ngOnInit() { + super.ngOnInit(); + } + + /** + * Overrides the super function that activates this section (triggered on hover) + * Has an extra check to make sure the section can only be activated on non-mobile devices + * @param {Event} event The user event that triggered this function + */ + activateSection(event): void { + this.windowService.isXsOrSm().pipe( + first() + ).subscribe((isMobile) => { + if (!isMobile) { + super.activateSection(event); + } + }); + } + + /** + * Overrides the super function that deactivates this section (triggered on hover) + * Has an extra check to make sure the section can only be deactivated on non-mobile devices + * @param {Event} event The user event that triggered this function + */ + deactivateSection(event): void { + this.windowService.isXsOrSm().pipe( + first() + ).subscribe((isMobile) => { + if (!isMobile) { + super.deactivateSection(event); + } + }); + } + + /** + * Overrides the super function that toggles this section (triggered on click) + * Has an extra check to make sure the section can only be toggled on mobile devices + * @param {Event} event The user event that triggered this function + */ + toggleSection(event): void { + event.preventDefault(); + this.windowService.isXsOrSm().pipe( + first() + ).subscribe((isMobile) => { + if (isMobile) { + super.toggleSection(event); + } + }); + } +} diff --git a/src/app/navbar/navbar-section/navbar-section.component.html b/src/app/navbar/navbar-section/navbar-section.component.html new file mode 100644 index 0000000000..b257a7bd57 --- /dev/null +++ b/src/app/navbar/navbar-section/navbar-section.component.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss b/src/app/navbar/navbar-section/navbar-section.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/navbar/navbar-section/navbar-section.component.scss diff --git a/src/app/navbar/navbar-section/navbar-section.component.spec.ts b/src/app/navbar/navbar-section/navbar-section.component.spec.ts new file mode 100644 index 0000000000..af46227ec3 --- /dev/null +++ b/src/app/navbar/navbar-section/navbar-section.component.spec.ts @@ -0,0 +1,53 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NavbarSectionComponent } from './navbar-section.component'; +import { HostWindowService } from '../../shared/host-window.service'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MenuService } from '../../shared/menu/menu.service'; +import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub'; +import { Component } from '@angular/core'; +import { MenuServiceStub } from '../../shared/testing/menu-service-stub'; +import { of as observableOf } from 'rxjs'; + +describe('NavbarSectionComponent', () => { + let component: NavbarSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [NavbarSectionComponent, TestComponent], + providers: [ + { provide: 'sectionDataProvider', useValue: {} }, + { provide: MenuService, useValue: menuService }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) } + ] + }).overrideComponent(NavbarSectionComponent, { + set: { + entryComponents: [TestComponent] + } + }) + .compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); + + fixture = TestBed.createComponent(NavbarSectionComponent); + component = fixture.componentInstance; + spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { +} diff --git a/src/app/navbar/navbar-section/navbar-section.component.ts b/src/app/navbar/navbar-section/navbar-section.component.ts new file mode 100644 index 0000000000..e1488de3d3 --- /dev/null +++ b/src/app/navbar/navbar-section/navbar-section.component.ts @@ -0,0 +1,32 @@ +import { Component, Inject, Injector, OnInit } from '@angular/core'; +import { MenuSectionComponent } from '../../shared/menu/menu-section/menu-section.component'; +import { MenuService } from '../../shared/menu/menu.service'; +import { MenuID } from '../../shared/menu/initial-menus-state'; +import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator'; + +/** + * Represents a non-expandable section in the navbar + */ +@Component({ + selector: 'ds-navbar-section', + templateUrl: './navbar-section.component.html', + styleUrls: ['./navbar-section.component.scss'] +}) +@rendersSectionForMenu(MenuID.PUBLIC, false) +export class NavbarSectionComponent extends MenuSectionComponent implements OnInit { + /** + * This section resides in the Public Navbar + */ + menuID = MenuID.PUBLIC; + + constructor(@Inject('sectionDataProvider') menuSection, + protected menuService: MenuService, + protected injector: Injector + ) { + super(menuSection, menuService, injector); + } + + ngOnInit() { + super.ngOnInit(); + } +} diff --git a/src/app/navbar/navbar.component.html b/src/app/navbar/navbar.component.html new file mode 100644 index 0000000000..eb6164b26f --- /dev/null +++ b/src/app/navbar/navbar.component.html @@ -0,0 +1,17 @@ + + diff --git a/src/app/navbar/navbar.component.scss b/src/app/navbar/navbar.component.scss new file mode 100644 index 0000000000..947b785196 --- /dev/null +++ b/src/app/navbar/navbar.component.scss @@ -0,0 +1,39 @@ +@import '../../styles/variables.scss'; + +nav.navbar { + border-bottom: 1px $gray-400 solid; + align-items: baseline; +} + +/** Mobile menu styling **/ +@media screen and (max-width: map-get($grid-breakpoints, md)) { + .navbar { + width: 100%; + background-color: $white; + position: absolute; + overflow: hidden; + height: 0; + &.open { + height: 100vh; //doesn't matter because wrapper is sticky + } + } +} + +@media screen and (min-width: map-get($grid-breakpoints, md)) { + .reset-padding-md { + margin-left: -$spacer/2; + margin-right: -$spacer/2; + } +} + +/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */ +.navbar-expand-md.navbar-container { + @media screen and (max-width: map-get($grid-breakpoints, md)) { + > .container { + padding: 0 $spacer; + } + padding: 0; + } +} + + diff --git a/src/app/navbar/navbar.component.spec.ts b/src/app/navbar/navbar.component.spec.ts new file mode 100644 index 0000000000..2d937fd84e --- /dev/null +++ b/src/app/navbar/navbar.component.spec.ts @@ -0,0 +1,52 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { of as observableOf } from 'rxjs'; + +import { NavbarComponent } from './navbar.component'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HostWindowService } from '../shared/host-window.service'; +import { HostWindowServiceStub } from '../shared/testing/host-window-service-stub'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuServiceStub } from '../shared/testing/menu-service-stub'; + +let comp: NavbarComponent; +let fixture: ComponentFixture; + +describe('NavbarComponent', () => { + const menuService = new MenuServiceStub(); + + // async beforeEach + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + NoopAnimationsModule, + ReactiveFormsModule], + declarations: [NavbarComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: MenuService, useValue: menuService }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); // compile template and css + })); + + // synchronous beforeEach + beforeEach(() => { + spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([])); + + fixture = TestBed.createComponent(NavbarComponent); + + comp = fixture.componentInstance; + + }); + + it('should create', () => { + expect(comp).toBeTruthy(); + }); +}); diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts new file mode 100644 index 0000000000..008a86599d --- /dev/null +++ b/src/app/navbar/navbar.component.ts @@ -0,0 +1,127 @@ +import { Component, Injector, OnInit } from '@angular/core'; +import { slideMobileNav } from '../shared/animations/slide'; +import { MenuComponent } from '../shared/menu/menu.component'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuID, MenuItemType } from '../shared/menu/initial-menus-state'; +import { TextMenuItemModel } from '../shared/menu/menu-item/models/text.model'; +import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; +import { HostWindowService } from '../shared/host-window.service'; + +/** + * Component representing the public navbar + */ +@Component({ + selector: 'ds-navbar', + styleUrls: ['navbar.component.scss'], + templateUrl: 'navbar.component.html', + animations: [slideMobileNav] +}) +export class NavbarComponent extends MenuComponent implements OnInit { + /** + * The menu ID of the Navbar is PUBLIC + * @type {MenuID.PUBLIC} + */ + menuID = MenuID.PUBLIC; + + constructor(protected menuService: MenuService, + protected injector: Injector, + public windowService: HostWindowService + ) { + super(menuService, injector); + } + + ngOnInit(): void { + this.createMenu(); + super.ngOnInit(); + } + + /** + * Initialize all menu sections and items for this menu + */ + createMenu() { + const menuList = [ + /* News */ + { + id: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.browse_global' + } as TextMenuItemModel, + index: 0 + }, + // { + // id: 'browse_global_communities_and_collections', + // parentID: 'browse_global', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.browse_global_communities_and_collections', + // link: '#' + // } as LinkMenuItemModel, + // }, + { + id: 'browse_global_global_by_title', + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_title', + link: '/browse/title' + } as LinkMenuItemModel, + }, + { + id: 'browse_global_global_by_issue_date', + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_issue_date', + link: '/browse/dateissued' + } as LinkMenuItemModel, + }, + { + id: 'browse_global_by_author', + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_author', + link: '/browse/author' + } as LinkMenuItemModel, + }, + { + id: 'browse_global_by_subject', + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_subject', + link: '/browse/subject' + } as LinkMenuItemModel, + }, + + /* Statistics */ + { + id: 'statistics', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: '#' + } as LinkMenuItemModel, + index: 2 + }, + ]; + menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection)); + + } + +} diff --git a/src/app/header/header.effects.spec.ts b/src/app/navbar/navbar.effects.spec.ts similarity index 52% rename from src/app/header/header.effects.spec.ts rename to src/app/navbar/navbar.effects.spec.ts index 97b428bf8c..897fc15be7 100644 --- a/src/app/header/header.effects.spec.ts +++ b/src/app/navbar/navbar.effects.spec.ts @@ -1,26 +1,31 @@ import { TestBed } from '@angular/core/testing'; -import { HeaderEffects } from './header.effects'; -import { HeaderCollapseAction } from './header.actions'; +import { NavbarEffects } from './navbar.effects'; import { HostWindowResizeAction } from '../shared/host-window.actions'; import { Observable } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; import { cold, hot } from 'jasmine-marbles'; import * as fromRouter from '@ngrx/router-store'; +import { CollapseMenuAction } from '../shared/menu/menu.actions'; +import { MenuID } from '../shared/menu/initial-menus-state'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuServiceStub } from '../shared/testing/menu-service-stub'; -describe('HeaderEffects', () => { - let headerEffects: HeaderEffects; +describe('NavbarEffects', () => { + let navbarEffects: NavbarEffects; let actions: Observable; + const menuService = new MenuServiceStub(); beforeEach(() => { TestBed.configureTestingModule({ providers: [ - HeaderEffects, + NavbarEffects, provideMockActions(() => actions), + { provide: MenuService, useValue: menuService }, // other providers ], }); - headerEffects = TestBed.get(HeaderEffects); + navbarEffects = TestBed.get(NavbarEffects); }); describe('resize$', () => { @@ -28,9 +33,9 @@ describe('HeaderEffects', () => { it('should return a COLLAPSE action in response to a RESIZE action', () => { actions = hot('--a-', { a: new HostWindowResizeAction(800, 600) }); - const expected = cold('--b-', { b: new HeaderCollapseAction() }); + const expected = cold('--b-', { b: new CollapseMenuAction(MenuID.PUBLIC) }); - expect(headerEffects.resize$).toBeObservable(expected); + expect(navbarEffects.resize$).toBeObservable(expected); }); }); @@ -40,9 +45,9 @@ describe('HeaderEffects', () => { it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => { actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION } }); - const expected = cold('--b-', { b: new HeaderCollapseAction() }); + const expected = cold('--b-', { b: new CollapseMenuAction(MenuID.PUBLIC) }); - expect(headerEffects.routeChange$).toBeObservable(expected); + expect(navbarEffects.routeChange$).toBeObservable(expected); }); }); diff --git a/src/app/navbar/navbar.effects.ts b/src/app/navbar/navbar.effects.ts new file mode 100644 index 0000000000..9868b67e34 --- /dev/null +++ b/src/app/navbar/navbar.effects.ts @@ -0,0 +1,62 @@ +import { first, map, switchMap } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects' +import * as fromRouter from '@ngrx/router-store'; + +import { HostWindowActionTypes } from '../shared/host-window.actions'; +import { + CollapseMenuAction, + ExpandMenuPreviewAction, + MenuActionTypes +} from '../shared/menu/menu.actions'; +import { MenuID } from '../shared/menu/initial-menus-state'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuState } from '../shared/menu/menu.reducer'; + +@Injectable() +export class NavbarEffects { + menuID = MenuID.PUBLIC; + /** + * Effect that collapses the public menu on window resize + * @type {Observable} + */ + @Effect() resize$ = this.actions$ + .pipe( + ofType(HostWindowActionTypes.RESIZE), + map(() => new CollapseMenuAction(this.menuID)) + ); + + /** + * Effect that collapses the public menu on reroute + * @type {Observable} + */ + @Effect() routeChange$ = this.actions$ + .pipe( + ofType(fromRouter.ROUTER_NAVIGATION), + map(() => new CollapseMenuAction(this.menuID)) + ); + /** + * Effect that collapses the public menu when the admin sidebar opens + * @type {Observable} + */ + @Effect() openAdminSidebar$ = this.actions$ + .pipe( + ofType(MenuActionTypes.EXPAND_MENU_PREVIEW), + switchMap((action: ExpandMenuPreviewAction) => { + return this.menuService.getMenu(action.menuID).pipe( + first(), + map((menu: MenuState) => { + if (menu.id === MenuID.ADMIN) { + if (!menu.previewCollapsed && menu.collapsed) { + return new CollapseMenuAction(MenuID.PUBLIC) + } + } + return { type: 'NO_ACTION' }; + })); + }) + ); + constructor(private actions$: Actions, private menuService: MenuService) { + + } + +} diff --git a/src/app/navbar/navbar.module.ts b/src/app/navbar/navbar.module.ts new file mode 100644 index 0000000000..363662d614 --- /dev/null +++ b/src/app/navbar/navbar.module.ts @@ -0,0 +1,45 @@ +import { SharedModule } from '../shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { CoreModule } from '../core/core.module'; +import { NavbarEffects } from './navbar.effects'; +import { NavbarSectionComponent } from './navbar-section/navbar-section.component'; +import { ExpandableNavbarSectionComponent } from './expandable-navbar-section/expandable-navbar-section.component'; +import { NavbarComponent } from './navbar.component'; + +const effects = [ + NavbarEffects +]; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + EffectsModule.forFeature(effects), + CoreModule.forRoot() + ], + declarations: [ + NavbarComponent, + NavbarSectionComponent, + ExpandableNavbarSectionComponent + ], + providers: [ + + ], + entryComponents: [ + NavbarSectionComponent, + ExpandableNavbarSectionComponent + ], + exports: [ + NavbarComponent, + NavbarSectionComponent, + ExpandableNavbarSectionComponent + ] +}) + +/** + * This module handles all components and pipes that are necessary for the horizontal navigation bar + */ +export class NavbarModule { +} diff --git a/src/app/pagenotfound/pagenotfound.component.html b/src/app/pagenotfound/pagenotfound.component.html index fce500f376..e85316b0ec 100644 --- a/src/app/pagenotfound/pagenotfound.component.html +++ b/src/app/pagenotfound/pagenotfound.component.html @@ -1,4 +1,4 @@ -
    +

    404

    {{"404.page-not-found" | translate}}


    diff --git a/src/app/pagenotfound/pagenotfound.component.ts b/src/app/pagenotfound/pagenotfound.component.ts index e7923b3466..6e173b4139 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -1,14 +1,33 @@ import { ServerResponseService } from '../shared/services/server-response.service'; -import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core'; +import { AuthService } from '../core/auth/auth.service'; +/** + * This component representing the `PageNotFound` DSpace page. + */ @Component({ selector: 'ds-pagenotfound', styleUrls: ['./pagenotfound.component.scss'], templateUrl: './pagenotfound.component.html', changeDetection: ChangeDetectionStrategy.Default }) -export class PageNotFoundComponent { - constructor(responseService: ServerResponseService) { - responseService.setNotFound(); +export class PageNotFoundComponent implements OnInit { + + /** + * Initialize instance variables + * + * @param {AuthService} authservice + * @param {ServerResponseService} responseService + */ + constructor(private authservice: AuthService, private responseService: ServerResponseService) { + this.responseService.setNotFound(); } + + /** + * Remove redirect url from the state + */ + ngOnInit(): void { + this.authservice.clearRedirectUrl(); + } + } diff --git a/src/app/shared/alert/alert.component.html b/src/app/shared/alert/alert.component.html new file mode 100644 index 0000000000..0b30edb5cc --- /dev/null +++ b/src/app/shared/alert/alert.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/alert/alert.component.scss b/src/app/shared/alert/alert.component.scss new file mode 100644 index 0000000000..1a70081367 --- /dev/null +++ b/src/app/shared/alert/alert.component.scss @@ -0,0 +1,3 @@ +.close:focus { + outline: none !important; +} diff --git a/src/app/shared/alert/alert.component.spec.ts b/src/app/shared/alert/alert.component.spec.ts new file mode 100644 index 0000000000..e235e27b28 --- /dev/null +++ b/src/app/shared/alert/alert.component.spec.ts @@ -0,0 +1,114 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowserModule, By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { AlertComponent } from './alert.component'; +import { createTestComponent } from '../testing/utils'; +import { AlertType } from './aletr-type'; + +describe('AlertComponent test suite', () => { + + let comp: AlertComponent; + let compAsAny: any; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + NoopAnimationsModule, + TranslateModule.forRoot() + ], + declarations: [ + AlertComponent, + TestComponent + ], + providers: [ + ChangeDetectorRef, + AlertComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + 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 AlertComponent', inject([AlertComponent], (app: AlertComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(AlertComponent); + comp = fixture.componentInstance; + compAsAny = comp; + comp.content = 'test alert'; + comp.dismissible = true; + comp.type = AlertType.Info; + fixture.detectChanges(); + }); + + it('should display close icon when dismissible is true', () => { + + const btn = fixture.debugElement.query(By.css('.close')); + expect(btn).toBeDefined(); + }); + + it('should not display close icon when dismissible is false', () => { + comp.dismissible = false; + fixture.detectChanges(); + + const btn = fixture.debugElement.query(By.css('.close')); + expect(btn).toBeDefined(); + }); + + it('should dismiss alert when click on close icon', () => { + spyOn(comp, 'dismiss'); + const btn = fixture.debugElement.query(By.css('.close')); + + btn.nativeElement.click(); + + expect(comp.dismiss).toHaveBeenCalled(); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + content = 'test alert'; + dismissible = true; + type = AlertType.Info; +} diff --git a/src/app/shared/alert/alert.component.ts b/src/app/shared/alert/alert.component.ts new file mode 100644 index 0000000000..93535d2057 --- /dev/null +++ b/src/app/shared/alert/alert.component.ts @@ -0,0 +1,76 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; +import { trigger } from '@angular/animations'; + +import { AlertType } from './aletr-type'; +import { fadeOutLeave, fadeOutState } from '../animations/fade'; + +/** + * This component allow to create div that uses the Bootstrap's Alerts component. + */ +@Component({ + selector: 'ds-alert', + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('enterLeave', [ + fadeOutLeave, fadeOutState, + ]) + ], + templateUrl: './alert.component.html', + styleUrls: ['./alert.component.scss'] +}) +export class AlertComponent { + + /** + * The alert content + */ + @Input() content: string; + + /** + * A boolean representing if alert is dismissible + */ + @Input() dismissible = false; + + /** + * The alert type + */ + @Input() type: AlertType; + + /** + * An event fired when alert is dismissed. + */ + @Output() close: EventEmitter = new EventEmitter(); + + /** + * The initial animation name + */ + public animate = 'fadeIn'; + + /** + * A boolean representing if alert is dismissed or not + */ + public dismissed = false; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} cdr + */ + constructor(private cdr: ChangeDetectorRef) { + } + + /** + * Dismiss div with animation + */ + dismiss() { + if (this.dismissible) { + this.animate = 'fadeOut'; + this.cdr.detectChanges(); + setTimeout(() => { + this.dismissed = true; + this.close.emit(); + this.cdr.detectChanges(); + }, 300); + + } + } +} diff --git a/src/app/shared/alert/aletr-type.ts b/src/app/shared/alert/aletr-type.ts new file mode 100644 index 0000000000..aacfb451f9 --- /dev/null +++ b/src/app/shared/alert/aletr-type.ts @@ -0,0 +1,6 @@ +export enum AlertType { + Success = 'alert-success', + Error = 'alert-danger', + Info = 'alert-info', + Warning = 'alert-warning' +} diff --git a/src/app/shared/animations/bgColor.ts b/src/app/shared/animations/bgColor.ts new file mode 100644 index 0000000000..91fd3116b1 --- /dev/null +++ b/src/app/shared/animations/bgColor.ts @@ -0,0 +1,26 @@ +import { + animate, + animateChild, + group, query, + state, + style, + transition, + trigger +} from '@angular/animations'; + +const startStyle = style({ backgroundColor: '{{ startColor }}' }); +const endStyle = style({ backgroundColor: '{{ endColor }}' }); + +export const bgColor = trigger('bgColor', + [ + state('startBackground', startStyle, { params: { startColor: '*' } }), + state('endBackground', endStyle, { params: { endColor: '*' } }), + transition('startBackground <=> endBackground', + group( + [ + query('@*', animateChild()), + animate('200ms'), + + ] + )) + ]); diff --git a/src/app/shared/animations/focus.ts b/src/app/shared/animations/focus.ts index 33a5010629..ec9c5020ce 100644 --- a/src/app/shared/animations/focus.ts +++ b/src/app/shared/animations/focus.ts @@ -2,18 +2,18 @@ import { animate, state, transition, trigger, style } from '@angular/animations' export const focusShadow = trigger('focusShadow', [ - state('focus', style({ 'box-shadow': 'rgba(119, 119, 119, 0.6) 0px 0px 6px' })), + state('focus', style({ boxShadow: 'rgba(119, 119, 119, 0.6) 0px 0px 6px' })), - state('blur', style({ 'box-shadow': 'none' })), + state('blur', style({ boxShadow: 'none' })), - transition('focus <=> blur', animate(250)) + transition('focus <=> blur', [animate('250ms')]) ]); export const focusBackground = trigger('focusBackground', [ - state('focus', style({ 'background-color': 'rgba(119, 119, 119, 0.1)' })), + state('focus', style({ backgroundColor: 'rgba(119, 119, 119, 0.1)' })), - state('blur', style({ 'background-color': 'transparent' })), + state('blur', style({ backgroundColor: 'transparent' })), - transition('focus <=> blur', animate(250)) + transition('focus <=> blur', [animate('250ms')]) ]); diff --git a/src/app/shared/animations/rotate.ts b/src/app/shared/animations/rotate.ts index 00f8b01452..d6337d4b3d 100644 --- a/src/app/shared/animations/rotate.ts +++ b/src/app/shared/animations/rotate.ts @@ -1,14 +1,14 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; -export const rotateInState = state('rotateIn', style({opacity: 1, transform: 'rotate(0deg)'})); -export const rotateEnter = transition('* => rotateIn', [ - style({opacity: 0, transform: 'rotate(5deg)'}), +export const rotateInState = state('rotateIn', style({ opacity: 1, transform: 'rotate(0deg)' })); +export const rotateEnter = transition('* => rotateIn', [ + style({ opacity: 0, transform: 'rotate(5deg)' }), animate('400ms ease-in-out') ]); -export const rotateOutState = state('rotateOut', style({opacity: 0, transform: 'rotate(5deg)'})); +export const rotateOutState = state('rotateOut', style({ opacity: 0, transform: 'rotate(5deg)' })); export const rotateLeave = transition('rotateIn => rotateOut', [ - style({opacity: 1, transform: 'rotate(0deg)'}), + style({ opacity: 1, transform: 'rotate(0deg)' }), animate('400ms ease-in-out') ]); @@ -24,3 +24,16 @@ export const rotateInOut = trigger('rotateInOut', [ rotateEnter, rotateLeave ]); + +const expandedStyle = { transform: 'rotate(90deg)' }; +const collapsedStyle = { transform: 'rotate(0deg)' }; + +export const rotate = trigger('rotate', + [ + state('expanded', style(expandedStyle)), + state('collapsed', style(collapsedStyle)), + transition('expanded <=> collapsed', [ + animate('200ms') + ]) + + ]); diff --git a/src/app/shared/animations/slide.ts b/src/app/shared/animations/slide.ts index ee16f9936f..38bfaaddca 100644 --- a/src/app/shared/animations/slide.ts +++ b/src/app/shared/animations/slide.ts @@ -1,10 +1,72 @@ -import { animate, state, style, transition, trigger } from '@angular/animations'; +import { + animate, + animateChild, + group, + query, + state, + style, + transition, + trigger +} from '@angular/animations'; export const slide = trigger('slide', [ - state('expanded', style({ height: '*' })), + state('collapsed', style({ height: 0 })), + state('void', style({ height: 0 })), + state('*', style({ height: '*' })), + transition(':enter', [animate('200ms')]), + transition(':leave', [animate('200ms')]), + transition('expanded <=> collapsed', animate(250)) +]); + +export const slideHorizontal = trigger('slideHorizontal', [ + state('void', style({ width: 0 })), + state('*', style({ width: '*' })), + transition(':enter', [animate('200ms')]), + transition(':leave', [animate('200ms')]) +]); + +export const slideMobileNav = trigger('slideMobileNav', [ + + state('expanded', style({ height: '100vh' })), state('collapsed', style({ height: 0 })), - transition('expanded <=> collapsed', animate(250)) + transition('expanded <=> collapsed', animate('300ms')) +]); + +const collapsedStyle = style({ marginLeft: '-{{ sidebarWidth }}' }); +const expandedStyle = style({ marginLeft: '0' }); +const options = { params: { sidebarWidth: '*' } }; + +export const slideSidebar = trigger('slideSidebar', [ + + transition('expanded => collapsed', + group + ( + [ + query('@*', animateChild()), + query('.sidebar-collapsible', expandedStyle, options), + query('.sidebar-collapsible', animate('300ms ease-in-out', collapsedStyle)) + ], + )), + + transition('collapsed => expanded', + group + ( + [ + query('@*', animateChild()), + query('.sidebar-collapsible', collapsedStyle), + query('.sidebar-collapsible', animate('300ms ease-in-out', expandedStyle), options) + ] + )) +]); + +export const slideSidebarPadding = trigger('slideSidebarPadding', [ + state('hidden', style({ paddingLeft: 0 })), + state('shown', style({ paddingLeft: '{{ collapsedSidebarWidth }}' }), { params: { collapsedSidebarWidth: '*' } }), + state('expanded', style({ paddingLeft: '{{ totalSidebarWidth }}' }), { params: { totalSidebarWidth: '*' } }), + transition('hidden <=> shown', [animate('200ms')]), + transition('hidden <=> expanded', [animate('200ms')]), + transition('shown <=> expanded', [animate('200ms')]), ]); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index cc9b8c410b..97c3e39353 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -1,25 +1,26 @@ diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss index a8c7b84f56..7b7e7af12f 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss @@ -6,3 +6,8 @@ #loginDropdownMenu { min-height: 260px; } + +.dropdown-item.active, .dropdown-item:active, +.dropdown-item:hover, .dropdown-item:focus { + background-color: transparent !important; +} diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index 5c5dd11d75..ff4948caa0 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -229,7 +229,7 @@ describe('AuthNavMenuComponent', () => { component = null; }); it('should render logout dropdown menu', () => { - const logoutDropdownMenu = deNavMenuItem.query(By.css('div[id=logoutDropdownMenu]')); + const logoutDropdownMenu = deNavMenuItem.query(By.css('ul[id=logoutDropdownMenu]')); expect(logoutDropdownMenu.nativeElement).toBeDefined(); }); }) diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts index 4361163538..1b39ad15d9 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -1,6 +1,6 @@ -import { of as observableOf, Observable , Subscription } from 'rxjs'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; -import { map, filter } from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import { Component, OnInit } from '@angular/core'; import { RouterReducerState } from '@ngrx/router-store'; import { select, Store } from '@ngrx/store'; @@ -9,13 +9,9 @@ import { fadeInOut, fadeOut } from '../animations/fade'; import { HostWindowService } from '../host-window.service'; import { AppState, routerStateSelector } from '../../app.reducer'; import { isNotUndefined } from '../empty.util'; -import { - getAuthenticatedUser, - isAuthenticated, - isAuthenticationLoading -} from '../../core/auth/selectors'; +import { getAuthenticatedUser, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { EPerson } from '../../core/eperson/models/eperson.model'; -import { AuthService, LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; +import { LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; @Component({ selector: 'ds-auth-nav-menu', @@ -45,8 +41,7 @@ export class AuthNavMenuComponent implements OnInit { public sub: Subscription; constructor(private store: Store, - private windowService: HostWindowService, - private authService: AuthService + private windowService: HostWindowService ) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -63,14 +58,9 @@ export class AuthNavMenuComponent implements OnInit { this.showAuth = this.store.pipe( select(routerStateSelector), filter((router: RouterReducerState) => isNotUndefined(router) && isNotUndefined(router.state)), - map((router: RouterReducerState) => { - const url = router.state.url; - const show = !router.state.url.startsWith(LOGIN_ROUTE) && !router.state.url.startsWith(LOGOUT_ROUTE); - if (show) { - this.authService.setRedirectUrl(url); - } - return show; - }) + map((router: RouterReducerState) => (!router.state.url.startsWith(LOGIN_ROUTE) + && !router.state.url.startsWith(LOGOUT_ROUTE)) + ) ); } } diff --git a/src/app/shared/authority-confidence/authority-confidence-state.directive.ts b/src/app/shared/authority-confidence/authority-confidence-state.directive.ts new file mode 100644 index 0000000000..6362daf3c7 --- /dev/null +++ b/src/app/shared/authority-confidence/authority-confidence-state.directive.ts @@ -0,0 +1,148 @@ +import { + Directive, + ElementRef, EventEmitter, + HostListener, + Inject, + Input, + OnChanges, + Output, + Renderer2, + SimpleChanges +} from '@angular/core'; + +import { findIndex } from 'lodash'; + +import { AuthorityValue } from '../../core/integration/models/authority.value'; +import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; +import { ConfidenceType } from '../../core/integration/models/confidence-type'; +import { isNotEmpty, isNull } from '../empty.util'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { ConfidenceIconConfig } from '../../../config/submission-config.interface'; + +/** + * Directive to add to the element a bootstrap utility class based on metadata confidence value + */ +@Directive({ + selector: '[dsAuthorityConfidenceState]' +}) +export class AuthorityConfidenceStateDirective implements OnChanges { + + /** + * The metadata value + */ + @Input() authorityValue: AuthorityValue | FormFieldMetadataValueObject | string; + + /** + * A boolean representing if to show html icon if authority value is empty + */ + @Input() visibleWhenAuthorityEmpty = true; + + /** + * The css class applied before directive changes + */ + private previousClass: string = null; + + /** + * The css class applied after directive changes + */ + private newClass: string; + + /** + * An event fired when click on element that has a confidence value empty or different from CF_ACCEPTED + */ + @Output() whenClickOnConfidenceNotAccepted: EventEmitter = new EventEmitter(); + + /** + * Listener to click event + */ + @HostListener('click') onClick() { + if (isNotEmpty(this.authorityValue) && this.getConfidenceByValue(this.authorityValue) !== ConfidenceType.CF_ACCEPTED) { + this.whenClickOnConfidenceNotAccepted.emit(this.getConfidenceByValue(this.authorityValue)); + } + } + + /** + * Initialize instance variables + * + * @param {GlobalConfig} EnvConfig + * @param {ElementRef} elem + * @param {Renderer2} renderer + */ + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private elem: ElementRef, + private renderer: Renderer2 + ) { + } + + /** + * Apply css class to element whenever authority value change + * + * @param {SimpleChanges} changes + */ + ngOnChanges(changes: SimpleChanges): void { + if (!changes.authorityValue.firstChange) { + this.previousClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.previousValue)) + } + this.newClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.currentValue)); + + if (isNull(this.previousClass)) { + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } else if (this.previousClass !== this.newClass) { + this.renderer.removeClass(this.elem.nativeElement, this.previousClass); + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } + } + + /** + * Apply css class to element after view init + */ + ngAfterViewInit() { + if (isNull(this.previousClass)) { + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } else if (this.previousClass !== this.newClass) { + this.renderer.removeClass(this.elem.nativeElement, this.previousClass); + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } + } + + /** + * Return confidence value as ConfidenceType + * + * @param value + */ + private getConfidenceByValue(value: any): ConfidenceType { + let confidence: ConfidenceType = ConfidenceType.CF_UNSET; + + if (isNotEmpty(value) && value instanceof AuthorityValue && value.hasAuthority()) { + confidence = ConfidenceType.CF_ACCEPTED; + } + + if (isNotEmpty(value) && value instanceof FormFieldMetadataValueObject) { + confidence = value.confidence; + } + + return confidence; + } + + /** + * Return the properly css class based on confidence value + * + * @param confidence + */ + private getClassByConfidence(confidence: any): string { + if (!this.visibleWhenAuthorityEmpty && confidence === ConfidenceType.CF_UNSET) { + return 'd-none'; + } + + const confidenceIcons: ConfidenceIconConfig[] = this.EnvConfig.submission.icons.authority.confidence; + + const confidenceIndex: number = findIndex(confidenceIcons, {value: confidence}); + + const defaultconfidenceIndex: number = findIndex(confidenceIcons, {value: 'default' as any}); + const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].style : ''; + + return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].style : defaultClass; + } + +} diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index f30c5b905c..bad9f3fe8c 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -1,12 +1,42 @@ -

    {{title}}

    +

    {{title | translate}}

    +
    - - +
    + + +
    +
    +
    +
    +
    + +
    + + + + +
    +
    +
    +
    +
      +
    • + +
    • +
    +
    + + +
    +
    - + +
    diff --git a/src/app/shared/browse-by/browse-by.component.scss b/src/app/shared/browse-by/browse-by.component.scss index e69de29bb2..5d847a8609 100644 --- a/src/app/shared/browse-by/browse-by.component.scss +++ b/src/app/shared/browse-by/browse-by.component.scss @@ -0,0 +1,8 @@ +:host { + .dropdown-toggle::after { + display: none; + } + .dropdown-item { + padding-left: 20px; + } +} diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 2417dde7ca..bae345d009 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -1,19 +1,68 @@ import { BrowseByComponent } from './browse-by.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { SharedModule } from '../shared.module'; +import { CommonModule } from '@angular/common'; +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 { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { StoreModule } from '@ngrx/store'; +import { MockTranslateLoader } from '../mocks/mock-translate-loader'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; describe('BrowseByComponent', () => { let comp: BrowseByComponent; let fixture: ComponentFixture; + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId-1', + metadata: [ + { + key: 'dc.title', + value: 'First Fake Title' + } + ] + }), + Object.assign(new Item(), { + id: 'fakeId-2', + metadata: [ + { + key: 'dc.title', + value: 'Second Fake Title' + } + ] + }) + ]; + const mockItemsRD$ = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), mockItems))); + beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule], + imports: [ + CommonModule, + TranslateModule.forRoot(), + SharedModule, + NgbModule.forRoot(), + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + RouterTestingModule, + BrowserAnimationsModule + ], declarations: [], + providers: [], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); @@ -41,4 +90,67 @@ describe('BrowseByComponent', () => { expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).toBeDefined(); }); + describe('when enableArrows is true and objects are defined', () => { + beforeEach(() => { + comp.enableArrows = true; + comp.objects$ = mockItemsRD$; + comp.paginationConfig = Object.assign(new PaginationComponentOptions(), { + id: 'test-pagination', + currentPage: 1, + pageSizeOptions: [5,10,15,20], + pageSize: 15 + }); + comp.sortConfig = Object.assign(new SortOptions('dc.title', SortDirection.ASC)); + fixture.detectChanges(); + }); + + describe('when clicking the previous arrow button', () => { + beforeEach(() => { + spyOn(comp.prev, 'emit'); + fixture.debugElement.query(By.css('#nav-prev')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.prev.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking the next arrow button', () => { + beforeEach(() => { + spyOn(comp.next, 'emit'); + fixture.debugElement.query(By.css('#nav-next')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.next.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different page size', () => { + beforeEach(() => { + spyOn(comp.pageSizeChange, 'emit'); + fixture.debugElement.query(By.css('.page-size-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.pageSizeChange.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different sort direction', () => { + beforeEach(() => { + spyOn(comp.sortDirectionChange, 'emit'); + fixture.debugElement.query(By.css('.sort-direction-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.sortDirectionChange.emit).toHaveBeenCalled(); + }); + }); + }); + }); diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 94cf81f46e..6c4bc78213 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -1,12 +1,12 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, OnInit, Output } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; -import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { fadeIn, fadeInOut } from '../animations/fade'; import { Observable } from 'rxjs'; -import { Item } from '../../core/shared/item.model'; import { ListableObject } from '../object-collection/shared/listable-object.model'; +import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by', @@ -20,11 +20,126 @@ import { ListableObject } from '../object-collection/shared/listable-object.mode /** * Component to display a browse-by page for any ListableObject */ -export class BrowseByComponent { +export class BrowseByComponent implements OnInit { + /** + * The i18n message to display as title + */ @Input() title: string; + + /** + * The list of objects to display + */ @Input() objects$: Observable>>; + + /** + * The pagination configuration used for the list + */ @Input() paginationConfig: PaginationComponentOptions; + + /** + * The sorting configuration used for the list + */ @Input() sortConfig: SortOptions; - @Input() currentUrl: string; - query: string; + + /** + * The type of StartsWith options used to define what component to render for the options + * Defaults to text + */ + @Input() type = StartsWithType.text; + + /** + * The list of options to render for the StartsWith component + */ + @Input() startsWithOptions = []; + + /** + * Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination + */ + @Input() enableArrows = false; + + /** + * If enableArrows is set to true, should it hide the options gear? + */ + @Input() hideGear = false; + + /** + * If enableArrows is set to true, emit when the previous button is clicked + */ + @Output() prev = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the next button is clicked + */ + @Output() next = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the page size is changed + */ + @Output() pageSizeChange = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the sort direction is changed + */ + @Output() sortDirectionChange = new EventEmitter(); + + /** + * An object injector used to inject the startsWithOptions to the switchable StartsWith component + */ + objectInjector: Injector; + + /** + * Declare SortDirection enumeration to use it in the template + */ + public sortDirections = SortDirection; + + public constructor(private injector: Injector) { + + } + + /** + * Go to the previous page + */ + goPrev() { + this.prev.emit(true); + } + + /** + * Go to the next page + */ + goNext() { + this.next.emit(true); + } + + /** + * Change the page size + * @param size + */ + doPageSizeChange(size) { + this.paginationConfig.pageSize = size; + this.pageSizeChange.emit(size); + } + + /** + * Change the sort direction + * @param direction + */ + doSortDirectionChange(direction) { + this.sortConfig.direction = direction; + this.sortDirectionChange.emit(direction); + } + + /** + * Get the switchable StartsWith component dependant on the type + */ + getStartsWithComponent() { + return getStartsWithComponent(this.type); + } + + ngOnInit(): void { + this.objectInjector = Injector.create({ + providers: [{ provide: 'startsWithOptions', useFactory: () => (this.startsWithOptions), deps:[] }], + parent: this.injector + }); + } + } diff --git a/src/app/shared/chips/chips.component.html b/src/app/shared/chips/chips.component.html index 21ce99ecdb..db8f08dad0 100644 --- a/src/app/shared/chips/chips.component.html +++ b/src/app/shared/chips/chips.component.html @@ -1,29 +1,43 @@
    \ No newline at end of file diff --git a/src/app/shared/input-suggestions/input-suggestions.component.scss b/src/app/shared/input-suggestions/input-suggestions.component.scss index bea74cf7af..f2587e1b6f 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.scss +++ b/src/app/shared/input-suggestions/input-suggestions.component.scss @@ -1,8 +1,11 @@ +@import "../../../styles/_variables.scss"; + .autocomplete { width: 100%; .dropdown-item { white-space: normal; word-break: break-word; + padding: $input-padding-y $input-padding-x; &:focus { outline: none; } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts index 8b6cdd2aa5..1f16a84b2c 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.spec.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts @@ -77,7 +77,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the last element ', () => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(lastLink.nativeElement); }); @@ -103,7 +103,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the first element ', () => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(firstLink.nativeElement); }); @@ -117,7 +117,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second element', () => { - const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a')); + const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLink.nativeElement); }); @@ -126,7 +126,7 @@ describe('InputSuggestionsComponent', () => { describe('when the first element is in focus', () => { beforeEach(() => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); firstLink.nativeElement.focus(); comp.selectedIndex = 0; fixture.detectChanges(); @@ -140,7 +140,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the last element ', () => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(lastLink.nativeElement); }); @@ -153,7 +153,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second element ', () => { - const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a')); + const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLink.nativeElement); }); @@ -162,7 +162,7 @@ describe('InputSuggestionsComponent', () => { describe('when the last element is in focus', () => { beforeEach(() => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); lastLink.nativeElement.focus(); comp.selectedIndex = suggestions.length - 1; fixture.detectChanges(); @@ -176,7 +176,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second last element ', () => { - const secondLastLink = de.query(By.css('.list-unstyled > li:nth-last-child(2) a')); + const secondLastLink = de.query(By.css('.dropdown-list > div:nth-last-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLastLink.nativeElement); }); @@ -189,7 +189,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the first element ', () => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(firstLink.nativeElement); }); @@ -294,7 +294,7 @@ describe('InputSuggestionsComponent', () => { const clickedIndex = 0; beforeEach(() => { spyOn(comp, 'onClickSuggestion'); - const clickedLink = de.query(By.css('.list-unstyled > li:nth-child(' + (clickedIndex + 1) + ') a')); + const clickedLink = de.query(By.css('.dropdown-list > div:nth-child(' + (clickedIndex + 1) + ') a')); clickedLink.triggerEventHandler('click', {} ); fixture.detectChanges(); }); diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index f77821d624..66c89870a8 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -1,29 +1,44 @@ import { Component, - ElementRef, EventEmitter, + ElementRef, + EventEmitter, + forwardRef, Input, + OnChanges, Output, - QueryList, SimpleChanges, + QueryList, + SimpleChanges, ViewChild, ViewChildren } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { hasValue, isNotEmpty } from '../empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../empty.util'; +import { InputSuggestion } from './input-suggestions.model'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ selector: 'ds-input-suggestions', styleUrls: ['./input-suggestions.component.scss'], - templateUrl: './input-suggestions.component.html' + templateUrl: './input-suggestions.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => InputSuggestionsComponent), + multi: true + } + ] }) /** * Component representing a form with a autocomplete functionality */ -export class InputSuggestionsComponent { +export class InputSuggestionsComponent implements ControlValueAccessor, OnChanges { /** * The suggestions that should be shown */ - @Input() suggestions: any[] = []; + @Input() suggestions: InputSuggestion[] = []; /** * The time waited to detect if any other input will follow before requesting the suggestions @@ -46,14 +61,9 @@ export class InputSuggestionsComponent { @Input() name; /** - * Value of the input field + * Whether or not the current input is valid */ - @Input() ngModel; - - /** - * Output for when the input field's value changes - */ - @Output() ngModelChange = new EventEmitter(); + @Input() valid = true; /** * Output for when the form is submitted @@ -65,6 +75,11 @@ export class InputSuggestionsComponent { */ @Output() clickSuggestion = new EventEmitter(); + /** + * Output for when something is typed in the input field + */ + @Output() typeSuggestion = new EventEmitter(); + /** * Output for when new suggestions should be requested */ @@ -94,12 +109,26 @@ export class InputSuggestionsComponent { */ @ViewChildren('suggestion') resultViews: QueryList; + /** + * Value of the input field + */ + _value: string; + + /** Fields needed to add ngModel */ + @Input() disabled = false; + propagateChange = (_: any) => { + /* Empty implementation */ + }; + propagateTouch = (_: any) => { + /* Empty implementation */ + }; + /** * When any of the inputs change, check if we should still show the suggestions */ ngOnChanges(changes: SimpleChanges) { if (hasValue(changes.suggestions)) { - this.show.next(isNotEmpty(changes.suggestions.currentValue)); + this.show.next(isNotEmpty(changes.suggestions.currentValue) && !changes.suggestions.firstChange); } } @@ -179,6 +208,7 @@ export class InputSuggestionsComponent { * Make sure that if a suggestion is clicked, the suggestions dropdown closes, does not reopen and the focus moves to the input field */ onClickSuggestion(data) { + this.value = data; this.clickSuggestion.emit(data); this.close(); this.blockReopen = true; @@ -193,8 +223,40 @@ export class InputSuggestionsComponent { find(data) { if (!this.blockReopen) { this.findSuggestions.emit(data); + this.typeSuggestion.emit(data); } this.blockReopen = false; } + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + /* START - Method's needed to add ngModel (ControlValueAccessor) to a component */ + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + this.propagateTouch = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + this.value = value; + } + + get value() { + return this._value; + } + + set value(val) { + this._value = val; + this.propagateChange(this._value); + } + /* END - Method's needed to add ngModel to a component */ } diff --git a/src/app/shared/input-suggestions/input-suggestions.model.ts b/src/app/shared/input-suggestions/input-suggestions.model.ts new file mode 100644 index 0000000000..1ccdbbe566 --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.model.ts @@ -0,0 +1,14 @@ +/** + * Interface representing a single suggestion for the input suggestions component + */ +export interface InputSuggestion { + /** + * The displayed value of the suggestion + */ + displayValue: string, + + /** + * The actual value of the suggestion + */ + value: string +} diff --git a/src/app/shared/lang-switch/lang-switch.component.html b/src/app/shared/lang-switch/lang-switch.component.html new file mode 100644 index 0000000000..745facc95c --- /dev/null +++ b/src/app/shared/lang-switch/lang-switch.component.html @@ -0,0 +1,12 @@ + diff --git a/src/app/shared/lang-switch/lang-switch.component.scss b/src/app/shared/lang-switch/lang-switch.component.scss new file mode 100644 index 0000000000..666ba2d49d --- /dev/null +++ b/src/app/shared/lang-switch/lang-switch.component.scss @@ -0,0 +1,3 @@ +.dropdown-toggle::after { + display:none; +} diff --git a/src/app/shared/lang-switch/lang-switch.component.spec.ts b/src/app/shared/lang-switch/lang-switch.component.spec.ts new file mode 100644 index 0000000000..bbaee604f7 --- /dev/null +++ b/src/app/shared/lang-switch/lang-switch.component.spec.ts @@ -0,0 +1,156 @@ +import {LangSwitchComponent} from './lang-switch.component'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core'; +import {TranslateLoader, TranslateModule, TranslateService} from '@ngx-translate/core'; +import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; +import { GLOBAL_CONFIG } from '../../../config'; +import {LangConfig} from '../../../config/lang-config.interface'; +import {Observable, of} from 'rxjs'; + +// This test is completely independent from any message catalogs or keys in the codebase +// The translation module is instantiated with these bogus messages that we aren't using anyway. + +// Double quotes are mandatory in JSON, so de-activating the tslint rule checking for single quotes here. +/* tslint:disable:quotemark */ +// JSON for the language files has double quotes around all literals +/* tslint:disable:object-literal-key-quotes */ +class CustomLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + return of({ + "footer": { + "copyright": "copyright © 2002-{{ year }}", + "link.dspace": "DSpace software", + "link.duraspace": "DuraSpace" + } + }); + } +} +/* tslint:enable:quotemark */ +/* tslint:enable:object-literal-key-quotes */ + +describe('LangSwitchComponent', () => { + + describe('with English and Deutsch activated, English as default', () => { + let component: LangSwitchComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let langSwitchElement: HTMLElement; + + let translate: TranslateService; + let http: HttpTestingController; + + beforeEach(async(() => { + + const mockConfig = { + languages: [{ + code: 'en', + label: 'English', + active: true, + }, { + code: 'de', + label: 'Deutsch', + active: true, + }] + }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, TranslateModule.forRoot( + { + loader: {provide: TranslateLoader, useClass: CustomLoader} + } + )], + declarations: [LangSwitchComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [TranslateService, {provide: GLOBAL_CONFIG, useValue: mockConfig}] + }).compileComponents() + .then(() => { + translate = TestBed.get(TranslateService); + translate.addLangs(mockConfig.languages.filter((langConfig:LangConfig) => langConfig.active === true).map((a) => a.code)); + translate.setDefaultLang('en'); + translate.use('en'); + http = TestBed.get(HttpTestingController); + fixture = TestBed.createComponent(LangSwitchComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + langSwitchElement = de.nativeElement; + }); + })); + + it('should create', () => { + expect(component).toBeDefined(); + }); + + it('should identify English as the label for the current active language in the component', async(() => { + fixture.detectChanges(); + expect(component.currentLangLabel()).toEqual('English'); + })); + + it('should be initialized with more than one language active', async(() => { + fixture.detectChanges(); + expect(component.moreThanOneLanguage).toBeTruthy(); + })); + + it('should define the main A HREF in the UI', (() => { + expect(langSwitchElement.querySelector('a')).toBeDefined(); + })); + }); + + describe('with English as the only active and also default language', () => { + + let component: LangSwitchComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let langSwitchElement: HTMLElement; + + let translate: TranslateService; + let http: HttpTestingController; + + beforeEach(async(() => { + + const mockConfig = { + languages: [{ + code: 'en', + label: 'English', + active: true, + }, { + code: 'de', + label: 'Deutsch', + active: false + }] + }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, TranslateModule.forRoot( + { + loader: {provide: TranslateLoader, useClass: CustomLoader} + } + )], + declarations: [LangSwitchComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [TranslateService, {provide: GLOBAL_CONFIG, useValue: mockConfig}] + }).compileComponents(); + translate = TestBed.get(TranslateService); + translate.addLangs(mockConfig.languages.filter((MyLangConfig) => MyLangConfig.active === true).map((a) => a.code)); + translate.setDefaultLang('en'); + translate.use('en'); + http = TestBed.get(HttpTestingController); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LangSwitchComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + langSwitchElement = de.nativeElement; + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); + + it('should not define the main header for the language switch, as it should be invisible', (() => { + expect(langSwitchElement.querySelector('a')).toBeNull(); + })); + + }); + +}); diff --git a/src/app/shared/lang-switch/lang-switch.component.ts b/src/app/shared/lang-switch/lang-switch.component.ts new file mode 100644 index 0000000000..88ffe89749 --- /dev/null +++ b/src/app/shared/lang-switch/lang-switch.component.ts @@ -0,0 +1,49 @@ +import {Component, Inject, OnInit} from '@angular/core'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import {TranslateService} from '@ngx-translate/core'; +import {LangConfig} from '../../../config/lang-config.interface'; + +@Component({ + selector: 'ds-lang-switch', + styleUrls: ['lang-switch.component.scss'], + templateUrl: 'lang-switch.component.html', +}) + +/** + * Component representing a switch for changing the interface language throughout the application + * If only one language is active, the component will disappear as there are no languages to switch to. + */ +export class LangSwitchComponent implements OnInit { + + // All of the languages that are active, meaning that a user can switch between them. + activeLangs: LangConfig[]; + + // A language switch only makes sense if there is more than one active language to switch between. + moreThanOneLanguage: boolean; + + constructor( + @Inject(GLOBAL_CONFIG) public config: GlobalConfig, + public translate: TranslateService + ) { + } + + ngOnInit(): void { + this.activeLangs = this.config.languages.filter((MyLangConfig) => MyLangConfig.active === true); + this.moreThanOneLanguage = (this.activeLangs.length > 1); + } + + /** + * Returns the label for the current language + */ + currentLangLabel(): string { + return this.activeLangs.find((MyLangConfig) => MyLangConfig.code === this.translate.currentLang).label; + } + + /** + * Returns the label for a specific language code + */ + langLabel(langcode: string): string { + return this.activeLangs.find((MyLangConfig) => MyLangConfig.code === langcode).label; + } + +} diff --git a/src/app/shared/loading/loading.component.html b/src/app/shared/loading/loading.component.html index c628f75f7b..efec4e597e 100644 --- a/src/app/shared/loading/loading.component.html +++ b/src/app/shared/loading/loading.component.html @@ -1,5 +1,5 @@
    - +
    diff --git a/src/app/shared/loading/loading.component.ts b/src/app/shared/loading/loading.component.ts index fec05b4720..d617d8c7a4 100644 --- a/src/app/shared/loading/loading.component.ts +++ b/src/app/shared/loading/loading.component.ts @@ -13,6 +13,7 @@ import { hasValue } from '../empty.util'; export class LoadingComponent implements OnDestroy, OnInit { @Input() message: string; + @Input() showMessage = true; private subscription: Subscription; diff --git a/src/app/shared/menu/initial-menus-state.ts b/src/app/shared/menu/initial-menus-state.ts new file mode 100644 index 0000000000..7b900540b6 --- /dev/null +++ b/src/app/shared/menu/initial-menus-state.ts @@ -0,0 +1,40 @@ +import { MenusState } from './menu.reducer'; + +/** + * Availavle Menu IDs + */ +export enum MenuID { + ADMIN = 'admin-sidebar', + PUBLIC = 'public' +} + +/** + * List of possible MenuItemTypes + */ +export enum MenuItemType { + TEXT, LINK, ALTMETRIC, SEARCH, ONCLICK +} + +/** + * The initial state of the menus + */ +export const initialMenusState: MenusState = { + [MenuID.ADMIN]: + { + id: MenuID.ADMIN, + collapsed: true, + previewCollapsed: true, + visible: false, + sections: {}, + sectionToSubsectionIndex: {} + }, + [MenuID.PUBLIC]: + { + id: MenuID.PUBLIC, + collapsed: true, + previewCollapsed: true, + visible: true, + sections: {}, + sectionToSubsectionIndex: {} + } +}; diff --git a/src/app/shared/menu/menu-item.decorator.ts b/src/app/shared/menu/menu-item.decorator.ts new file mode 100644 index 0000000000..3740a4eba4 --- /dev/null +++ b/src/app/shared/menu/menu-item.decorator.ts @@ -0,0 +1,26 @@ +import { MenuItemType } from './initial-menus-state'; + +const menuMenuItemComponentMap = new Map(); + +/** + * Decorator function to link a MenuItemType to a Component + * @param {MenuItemType} type The MenuItemType of the MenuSection's model + * @returns {(sectionComponent: GenericContructor) => void} + */ +export function rendersMenuItemForType(type: MenuItemType) { + return function decorator(sectionComponent: any) { + if (!sectionComponent) { + return; + } + menuMenuItemComponentMap.set(type, sectionComponent); + }; +} + +/** + * Retrieves the Component matching a given MenuItemType + * @param {MenuItemType} type The given MenuItemType + * @returns {GenericConstructor} The constructor of the Component that matches the MenuItemType + */ +export function getComponentForMenuItemType(type: MenuItemType) { + return menuMenuItemComponentMap.get(type); +} diff --git a/src/app/shared/menu/menu-item/link-menu-item.component.html b/src/app/shared/menu/menu-item/link-menu-item.component.html new file mode 100644 index 0000000000..76856e57ef --- /dev/null +++ b/src/app/shared/menu/menu-item/link-menu-item.component.html @@ -0,0 +1 @@ +{{item.text | translate}} \ No newline at end of file diff --git a/src/app/shared/menu/menu-item/link-menu-item.component.spec.ts b/src/app/shared/menu/menu-item/link-menu-item.component.spec.ts new file mode 100644 index 0000000000..f24ca86661 --- /dev/null +++ b/src/app/shared/menu/menu-item/link-menu-item.component.spec.ts @@ -0,0 +1,57 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { LinkMenuItemComponent } from './link-menu-item.component'; +import { RouterLinkDirectiveStub } from '../../testing/router-link-directive-stub'; +import { GLOBAL_CONFIG } from '../../../../config'; + +describe('LinkMenuItemComponent', () => { + let component: LinkMenuItemComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + const text = 'HELLO'; + const link = 'http://google.com'; + const nameSpace = 'dspace.com/'; + const globalConfig = { + ui: { + nameSpace: nameSpace + } + } as any; + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [LinkMenuItemComponent, RouterLinkDirectiveStub], + providers: [ + { provide: 'itemModelProvider', useValue: { text: text, link: link } }, + { provide: GLOBAL_CONFIG, useValue: globalConfig }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LinkMenuItemComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should contain the correct text', () => { + const textContent = debugElement.query(By.css('a')).nativeElement.textContent; + expect(textContent).toEqual(text); + }); + + it('should have the right routerLink attribute', () => { + const linkDes = fixture.debugElement.queryAll(By.directive(RouterLinkDirectiveStub)); + const routerLinkQuery = linkDes.map((de) => de.injector.get(RouterLinkDirectiveStub)); + + expect(routerLinkQuery.length).toBe(1); + expect(routerLinkQuery[0].routerLink).toBe(nameSpace + link); + }); +}); diff --git a/src/app/shared/menu/menu-item/link-menu-item.component.ts b/src/app/shared/menu/menu-item/link-menu-item.component.ts new file mode 100644 index 0000000000..02ce31843c --- /dev/null +++ b/src/app/shared/menu/menu-item/link-menu-item.component.ts @@ -0,0 +1,24 @@ +import { Component, Inject, Input } from '@angular/core'; +import { LinkMenuItemModel } from './models/link.model'; +import { MenuItemType } from '../initial-menus-state'; +import { rendersMenuItemForType } from '../menu-item.decorator'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; + +/** + * Component that renders a menu section of type LINK + */ +@Component({ + selector: 'ds-link-menu-item', + templateUrl: './link-menu-item.component.html' +}) +@rendersMenuItemForType(MenuItemType.LINK) +export class LinkMenuItemComponent { + item: LinkMenuItemModel; + constructor(@Inject('itemModelProvider') item: LinkMenuItemModel, @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { + this.item = item; + } + + getRouterLink() { + return this.EnvConfig.ui.nameSpace + this.item.link; + } +} diff --git a/src/app/shared/menu/menu-item/models/altmetric.model.ts b/src/app/shared/menu/menu-item/models/altmetric.model.ts new file mode 100644 index 0000000000..8dc3f01363 --- /dev/null +++ b/src/app/shared/menu/menu-item/models/altmetric.model.ts @@ -0,0 +1,10 @@ +import { MenuItemType } from '../../initial-menus-state'; +import { MenuItemModel } from './menu-item.model'; + +/** + * Model representing an Altmetric Menu Section + */ +export class AltmetricMenuItemModel implements MenuItemModel { + type = MenuItemType.ALTMETRIC; + url: string; +} diff --git a/src/app/shared/menu/menu-item/models/link.model.ts b/src/app/shared/menu/menu-item/models/link.model.ts new file mode 100644 index 0000000000..a5b3671f62 --- /dev/null +++ b/src/app/shared/menu/menu-item/models/link.model.ts @@ -0,0 +1,11 @@ +import { MenuItemModel } from './menu-item.model'; +import { MenuItemType } from '../../initial-menus-state'; + +/** + * Model representing an Link Menu Section + */ +export class LinkMenuItemModel implements MenuItemModel { + type = MenuItemType.LINK; + text: string; + link: string; +} diff --git a/src/app/shared/menu/menu-item/models/menu-item.model.ts b/src/app/shared/menu/menu-item/models/menu-item.model.ts new file mode 100644 index 0000000000..7bf5fca066 --- /dev/null +++ b/src/app/shared/menu/menu-item/models/menu-item.model.ts @@ -0,0 +1,8 @@ +import { MenuItemType } from '../../initial-menus-state'; + +/** + * Interface for models representing a Menu Section + */ +export interface MenuItemModel { + type: MenuItemType; +} diff --git a/src/app/shared/menu/menu-item/models/onclick.model.ts b/src/app/shared/menu/menu-item/models/onclick.model.ts new file mode 100644 index 0000000000..4cef3084f9 --- /dev/null +++ b/src/app/shared/menu/menu-item/models/onclick.model.ts @@ -0,0 +1,11 @@ +import { MenuItemModel } from './menu-item.model'; +import { MenuItemType } from '../../initial-menus-state'; + +/** + * Model representing an OnClick Menu Section + */ +export class OnClickMenuItemModel implements MenuItemModel { + type = MenuItemType.ONCLICK; + text: string; + function: () => {}; +} diff --git a/src/app/shared/menu/menu-item/models/search.model.ts b/src/app/shared/menu/menu-item/models/search.model.ts new file mode 100644 index 0000000000..e8eeda5501 --- /dev/null +++ b/src/app/shared/menu/menu-item/models/search.model.ts @@ -0,0 +1,11 @@ +import { MenuItemType } from '../../initial-menus-state'; +import { MenuItemModel } from './menu-item.model'; + +/** + * Model representing an Search Bar Menu Section + */ +export class SearchMenuItemModel implements MenuItemModel { + type = MenuItemType.SEARCH; + placeholder: string; + action: string; +} diff --git a/src/app/shared/menu/menu-item/models/text.model.ts b/src/app/shared/menu/menu-item/models/text.model.ts new file mode 100644 index 0000000000..bbaf7804d9 --- /dev/null +++ b/src/app/shared/menu/menu-item/models/text.model.ts @@ -0,0 +1,10 @@ +import { MenuItemType } from '../../initial-menus-state'; +import { MenuItemModel } from './menu-item.model'; + +/** + * Model representing an Text Menu Section + */ +export class TextMenuItemModel implements MenuItemModel { + type = MenuItemType.TEXT; + text: string; +} diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.html b/src/app/shared/menu/menu-item/onclick-menu-item.component.html new file mode 100644 index 0000000000..96c9049ab3 --- /dev/null +++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.html @@ -0,0 +1 @@ +{{item.text | translate}} \ No newline at end of file diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.scss b/src/app/shared/menu/menu-item/onclick-menu-item.component.scss new file mode 100644 index 0000000000..adb670aa6f --- /dev/null +++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.scss @@ -0,0 +1,3 @@ +a { + cursor: pointer; +} \ No newline at end of file diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.spec.ts b/src/app/shared/menu/menu-item/onclick-menu-item.component.spec.ts new file mode 100644 index 0000000000..dd031a96e0 --- /dev/null +++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.spec.ts @@ -0,0 +1,52 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TextMenuItemComponent } from './text-menu-item.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { OnClickMenuItemComponent } from './onclick-menu-item.component'; +import { OnClickMenuItemModel } from './models/onclick.model'; + +describe('OnClickMenuItemComponent', () => { + let component: OnClickMenuItemComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + const text = 'HELLO'; + const func = () => { + /* comment */ + }; + const item = Object.assign(new OnClickMenuItemModel(), { text, function: func }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [OnClickMenuItemComponent], + providers: [ + { provide: 'itemModelProvider', useValue: item }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + spyOn(item, 'function'); + fixture = TestBed.createComponent(OnClickMenuItemComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should contain the correct text', () => { + expect(component).toBeTruthy(); + }); + + it('should contain the text element', () => { + const textContent = debugElement.query(By.css('a')).nativeElement.textContent; + expect(textContent).toEqual(text); + }); + + it('should contain call the function on the item when clicked', () => { + debugElement.query(By.css('a.nav-link')).triggerEventHandler('click', {}); + expect(item.function).toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/menu/menu-item/onclick-menu-item.component.ts b/src/app/shared/menu/menu-item/onclick-menu-item.component.ts new file mode 100644 index 0000000000..95b896ed64 --- /dev/null +++ b/src/app/shared/menu/menu-item/onclick-menu-item.component.ts @@ -0,0 +1,20 @@ +import { Component, Inject } from '@angular/core'; +import { MenuItemType } from '../initial-menus-state'; +import { rendersMenuItemForType } from '../menu-item.decorator'; +import { OnClickMenuItemModel } from './models/onclick.model'; + +/** + * Component that renders a menu section of type ONCLICK + */ +@Component({ + selector: 'ds-onclick-menu-item', + styleUrls: ['./onclick-menu-item.component.scss'], + templateUrl: './onclick-menu-item.component.html' +}) +@rendersMenuItemForType(MenuItemType.ONCLICK) +export class OnClickMenuItemComponent { + item: OnClickMenuItemModel; + constructor(@Inject('itemModelProvider') item: OnClickMenuItemModel) { + this.item = item; + } +} diff --git a/src/app/shared/menu/menu-item/text-menu-item.component.html b/src/app/shared/menu/menu-item/text-menu-item.component.html new file mode 100644 index 0000000000..7ba353e5e7 --- /dev/null +++ b/src/app/shared/menu/menu-item/text-menu-item.component.html @@ -0,0 +1 @@ +{{item.text | translate}} \ No newline at end of file diff --git a/src/app/shared/menu/menu-item/text-menu-item.component.spec.ts b/src/app/shared/menu/menu-item/text-menu-item.component.spec.ts new file mode 100644 index 0000000000..46c9b21bd9 --- /dev/null +++ b/src/app/shared/menu/menu-item/text-menu-item.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TextMenuItemComponent } from './text-menu-item.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +describe('TextMenuItemComponent', () => { + let component: TextMenuItemComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + const text = 'HELLO'; + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [TextMenuItemComponent], + providers: [ + { provide: 'itemModelProvider', useValue: { text: text } }, + ], + schemas: [ NO_ERRORS_SCHEMA ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TextMenuItemComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should contain the correct text', () => { + expect(component).toBeTruthy(); + }); + + it('should contain the text element', () => { + const textContent = debugElement.query(By.css('span')).nativeElement.textContent; + expect(textContent).toEqual(text); + }); +}); diff --git a/src/app/shared/menu/menu-item/text-menu-item.component.ts b/src/app/shared/menu/menu-item/text-menu-item.component.ts new file mode 100644 index 0000000000..f7d3402be0 --- /dev/null +++ b/src/app/shared/menu/menu-item/text-menu-item.component.ts @@ -0,0 +1,19 @@ +import { Component, Inject, Input } from '@angular/core'; +import { TextMenuItemModel } from './models/text.model'; +import { MenuItemType } from '../initial-menus-state'; +import { rendersMenuItemForType } from '../menu-item.decorator'; + +/** + * Component that renders a menu section of type TEXT + */ +@Component({ + selector: 'ds-text-menu-item', + templateUrl: './text-menu-item.component.html', +}) +@rendersMenuItemForType(MenuItemType.TEXT) +export class TextMenuItemComponent { + item: TextMenuItemModel; + constructor(@Inject('itemModelProvider') item: TextMenuItemModel) { + this.item = item; + } +} diff --git a/src/app/shared/menu/menu-section.decorator.ts b/src/app/shared/menu/menu-section.decorator.ts new file mode 100644 index 0000000000..c27e870e13 --- /dev/null +++ b/src/app/shared/menu/menu-section.decorator.ts @@ -0,0 +1,31 @@ +import { MenuID } from './initial-menus-state'; + +const menuComponentMap = new Map(); + +/** + * Decorator function to render a MenuSection for a menu + * @param {MenuID} menuID The ID of the Menu in which the section is rendered + * @param {boolean} expandable True when the section should be expandable, false when if should not + * @returns {(menuSectionWrapperComponent: GenericConstructor) => void} + */ +export function rendersSectionForMenu(menuID: MenuID, expandable: boolean) { + return function decorator(menuSectionWrapperComponent: any) { + if (!menuSectionWrapperComponent) { + return; + } + if (!menuComponentMap.get(menuID)) { + menuComponentMap.set(menuID, new Map()); + } + menuComponentMap.get(menuID).set(expandable, menuSectionWrapperComponent); + }; +} + +/** + * Retrieves the component matching the given MenuID and whether or not it should be expandable + * @param {MenuID} menuID The ID of the Menu in which the section is rendered + * @param {boolean} expandable True when the section should be expandable, false when if should not + * @returns {GenericConstructor} The constructor of the matching Component + */ +export function getComponentForMenu(menuID: MenuID, expandable: boolean) { + return menuComponentMap.get(menuID).get(expandable); +} diff --git a/src/app/shared/menu/menu-section/menu-section.component.spec.ts b/src/app/shared/menu/menu-section/menu-section.component.spec.ts new file mode 100644 index 0000000000..ebb099fb56 --- /dev/null +++ b/src/app/shared/menu/menu-section/menu-section.component.spec.ts @@ -0,0 +1,76 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { MenuSectionComponent } from './menu-section.component'; +import { MenuService } from '../menu.service'; +import { MenuServiceStub } from '../../testing/menu-service-stub'; +import { MenuSection } from '../menu.reducer'; +import { of as observableOf } from 'rxjs'; +import { LinkMenuItemComponent } from '../menu-item/link-menu-item.component'; + +describe('MenuSectionComponent', () => { + let comp: MenuSectionComponent; + let fixture: ComponentFixture; + let menuService: MenuService; + const dummySection = { + id: 'section', + visible: true, + active: false + } as any; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule], + declarations: [MenuSectionComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: MenuService, useClass: MenuServiceStub }, + { provide: MenuSection, useValue: dummySection }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MenuSectionComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MenuSectionComponent); + comp = fixture.componentInstance; + menuService = (comp as any).menuService; + spyOn(comp as any, 'getMenuItemComponent').and.returnValue(LinkMenuItemComponent); + spyOn(comp as any, 'getItemModelInjector').and.returnValue(observableOf({})); + fixture.detectChanges(); + }); + + describe('toggleSection', () => { + beforeEach(() => { + spyOn(menuService, 'toggleActiveSection'); + comp.toggleSection(new Event('click')); + }); + it('should trigger the toggleActiveSection function on the menu service', () => { + expect(menuService.toggleActiveSection).toHaveBeenCalledWith(comp.menuID, dummySection.id); + }) + }); + + describe('activateSection', () => { + beforeEach(() => { + spyOn(menuService, 'activateSection'); + comp.activateSection(new Event('click')); + }); + it('should trigger the activateSection function on the menu service', () => { + expect(menuService.activateSection).toHaveBeenCalledWith(comp.menuID, dummySection.id); + }) + }); + + describe('deactivateSection', () => { + beforeEach(() => { + spyOn(menuService, 'deactivateSection'); + comp.deactivateSection(new Event('click')); + }); + it('should trigger the deactivateSection function on the menu service', () => { + expect(menuService.deactivateSection).toHaveBeenCalledWith(comp.menuID, dummySection.id); + }) + }); + +}); diff --git a/src/app/shared/menu/menu-section/menu-section.component.ts b/src/app/shared/menu/menu-section/menu-section.component.ts new file mode 100644 index 0000000000..f7e4f7d867 --- /dev/null +++ b/src/app/shared/menu/menu-section/menu-section.component.ts @@ -0,0 +1,127 @@ +import { Component, Injector } from '@angular/core'; +import { MenuService } from '../menu.service'; +import { MenuSection } from '../menu.reducer'; +import { getComponentForMenuItemType } from '../menu-item.decorator'; +import { MenuID, MenuItemType } from '../initial-menus-state'; +import { hasNoValue } from '../../empty.util'; +import { Observable } from 'rxjs/internal/Observable'; +import { MenuItemModel } from '../menu-item/models/menu-item.model'; +import { distinctUntilChanged } from 'rxjs/operators'; +import { GenericConstructor } from '../../../core/shared/generic-constructor'; + +/** + * A basic implementation of a menu section's component + */ +@Component({ + selector: 'ds-menu-section', + template: '' +}) +export class MenuSectionComponent { + + /** + * Observable that emits whether or not this section is currently active + */ + active: Observable; + + /** + * The ID of the menu this section resides in + */ + menuID: MenuID; + + /** + * List of Injectors for each dynamically rendered menu item of this section + */ + itemInjectors: Map = new Map(); + + /** + * List of child Components for each dynamically rendered menu item of this section + */ + itemComponents: Map> = new Map>(); + + /** + * List of available subsections in this section + */ + subSections: Observable; + + constructor(public section: MenuSection, protected menuService: MenuService, protected injector: Injector) { + } + + /** + * Set initial values for instance variables + */ + ngOnInit(): void { + this.active = this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged()); + this.initializeInjectorData(); + } + + /** + * Activate this section if it's currently inactive, deactivate it when it's currently active + * @param {Event} event The user event that triggered this method + */ + toggleSection(event: Event) { + event.preventDefault(); + this.menuService.toggleActiveSection(this.menuID, this.section.id); + } + + /** + * Activate this section + * @param {Event} event The user event that triggered this method + */ + activateSection(event: Event) { + event.preventDefault(); + this.menuService.activateSection(this.menuID, this.section.id); + } + + /** + * Deactivate this section + * @param {Event} event The user event that triggered this method + */ + deactivateSection(event: Event) { + event.preventDefault(); + this.menuService.deactivateSection(this.menuID, this.section.id); + } + + /** + * Method for initializing all injectors and component constructors for the menu items in this section + */ + private initializeInjectorData() { + this.itemInjectors.set(this.section.id, this.getItemModelInjector(this.section.model)); + this.itemComponents.set(this.section.id, this.getMenuItemComponent(this.section.model)); + this.subSections = this.menuService.getSubSectionsByParentID(this.menuID, this.section.id); + this.subSections.subscribe((sections: MenuSection[]) => { + sections.forEach((section: MenuSection) => { + this.itemInjectors.set(section.id, this.getItemModelInjector(section.model)); + this.itemComponents.set(section.id, this.getMenuItemComponent(section.model)); + }) + }) + } + + /** + * Retrieve the component for a given MenuItemModel object + * @param {MenuItemModel} itemModel The given MenuItemModel + * @returns {GenericConstructor} Emits the constructor of the Component that should be used to render this menu item model + */ + private getMenuItemComponent(itemModel?: MenuItemModel) { + if (hasNoValue(itemModel)) { + itemModel = this.section.model; + } + const type: MenuItemType = itemModel.type; + return getComponentForMenuItemType(type); + } + + /** + * Retrieve the Injector for a given MenuItemModel object + * @param {MenuItemModel} itemModel The given MenuItemModel + * @returns {Injector} The Injector that injects the data for this menu item into the item's component + */ + private getItemModelInjector(itemModel?: MenuItemModel) { + if (hasNoValue(itemModel)) { + itemModel = this.section.model; + } + return Injector.create({ + providers: [{ provide: 'itemModelProvider', useFactory: () => (itemModel), deps: [] }], + parent: this.injector + }); + } + +} diff --git a/src/app/shared/menu/menu.actions.ts b/src/app/shared/menu/menu.actions.ts new file mode 100644 index 0000000000..0c1533ed3b --- /dev/null +++ b/src/app/shared/menu/menu.actions.ts @@ -0,0 +1,226 @@ +import { Action } from '@ngrx/store'; +import { MenuID } from './initial-menus-state'; +import { type } from '../ngrx/type'; +import { MenuSection } from './menu.reducer'; + +/** + * 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 MenuActionTypes = { + COLLAPSE_MENU: type('dspace/menu/COLLAPSE_MENU'), + TOGGLE_MENU: type('dspace/menu/TOGGLE_MENU'), + EXPAND_MENU: type('dspace/menu/EXPAND_MENU'), + SHOW_MENU: type('dspace/menu/SHOW_MENU'), + HIDE_MENU: type('dspace/menu/HIDE_MENU'), + COLLAPSE_MENU_PREVIEW: type('dspace/menu/COLLAPSE_MENU_PREVIEW'), + EXPAND_MENU_PREVIEW: type('dspace/menu/EXPAND_MENU_PREVIEW'), + ADD_SECTION: type('dspace/menu-section/ADD_SECTION'), + REMOVE_SECTION: type('dspace/menu-section/REMOVE_SECTION'), + SHOW_SECTION: type('dspace/menu-section/SHOW_SECTION'), + HIDE_SECTION: type('dspace/menu-section/HIDE_SECTION'), + ACTIVATE_SECTION: type('dspace/menu-section/ACTIVATE_SECTION'), + DEACTIVATE_SECTION: type('dspace/menu-section/DEACTIVATE_SECTION'), + TOGGLE_ACTIVE_SECTION: type('dspace/menu-section/TOGGLE_ACTIVE_SECTION'), +}; + +/* tslint:disable:max-classes-per-file */ + +// MENU STATE ACTIONS +/** + * Action used to collapse a single menu + */ +export class CollapseMenuAction implements Action { + type = MenuActionTypes.COLLAPSE_MENU; + menuID: MenuID; + + constructor(menuID: MenuID) { + this.menuID = menuID; + } +} + +/** + * Action used to expand a single menu + */ +export class ExpandMenuAction implements Action { + type = MenuActionTypes.EXPAND_MENU; + menuID: MenuID; + + constructor(menuID: MenuID) { + this.menuID = menuID; + } +} + +/** + * Action used to collapse a single menu when it's expanded and expanded it when it's collapse + */ +export class ToggleMenuAction implements Action { + type = MenuActionTypes.TOGGLE_MENU; + menuID: MenuID; + + constructor(menuID: MenuID) { + this.menuID = menuID; + } +} + +/** + * Action used to show a single menu + */ +export class ShowMenuAction implements Action { + type = MenuActionTypes.SHOW_MENU; + menuID: MenuID; + + constructor(menuID: MenuID) { + this.menuID = menuID; + } +} + +/** + * Action used to hide a single menu + */ +export class HideMenuAction implements Action { + type = MenuActionTypes.HIDE_MENU; + menuID: MenuID; + + constructor(menuID: MenuID) { + this.menuID = menuID; + } +} + +/** + * Action used to collapse a single menu's preview + */ +export class CollapseMenuPreviewAction implements Action { + type = MenuActionTypes.COLLAPSE_MENU_PREVIEW; + menuID: MenuID; + + constructor(menuID: MenuID) { + this.menuID = menuID; + } +} + +/** + * Action used to expand a single menu's preview + */ +export class ExpandMenuPreviewAction implements Action { + type = MenuActionTypes.EXPAND_MENU_PREVIEW; + menuID: MenuID; + + constructor(menuID: MenuID) { + this.menuID = menuID; + } +} + +// MENU SECTION ACTIONS +/** + * Action used to perform state changes for a section of a certain menu + */ +export abstract class MenuSectionAction implements Action { + type; + menuID: MenuID; + id: string; + + constructor(menuID: MenuID, id: string) { + this.menuID = menuID; + this.id = id; + } +} + +/** + * Action used to add a section to a certain menu + */ +export class AddMenuSectionAction extends MenuSectionAction { + type = MenuActionTypes.ADD_SECTION; + section: MenuSection; + + constructor(menuID: MenuID, section: MenuSection) { + super(menuID, section.id); + this.section = section; + } +} + +/** + * Action used to remove a section from a certain menu + */ +export class RemoveMenuSectionAction extends MenuSectionAction { + type = MenuActionTypes.REMOVE_SECTION; + + constructor(menuID: MenuID, id: string) { + super(menuID, id); + + } +} + +/** + * Action used to hide a section of a certain menu + */ +export class HideMenuSectionAction extends MenuSectionAction { + type = MenuActionTypes.HIDE_SECTION; + + constructor(menuID: MenuID, id: string) { + super(menuID, id); + } +} + +/** + * Action used to show a section of a certain menu + */ +export class ShowMenuSectionAction extends MenuSectionAction { + type = MenuActionTypes.SHOW_SECTION; + + constructor(menuID: MenuID, id: string) { + super(menuID, id); + } +} + +/** + * Action used to make a section of a certain menu active + */ +export class ActivateMenuSectionAction extends MenuSectionAction { + type = MenuActionTypes.ACTIVATE_SECTION; + + constructor(menuID: MenuID, id: string) { + super(menuID, id); + } +} + +/** + * Action used to make a section of a certain menu inactive + */ +export class DeactivateMenuSectionAction extends MenuSectionAction { + type = MenuActionTypes.DEACTIVATE_SECTION; + + constructor(menuID: MenuID, id: string) { + super(menuID, id); + } +} + +/** + * Action used to make an active section of a certain menu inactive or an inactive section of a certain menu active + */ +export class ToggleActiveMenuSectionAction extends MenuSectionAction { + type = MenuActionTypes.TOGGLE_ACTIVE_SECTION; + + constructor(menuID: MenuID, id: string) { + super(menuID, id); + } +} + +export type MenuAction = + CollapseMenuAction + | ExpandMenuAction + | ToggleMenuAction + | ShowMenuAction + | HideMenuAction + | AddMenuSectionAction + | RemoveMenuSectionAction + | ShowMenuSectionAction + | HideMenuSectionAction + | ActivateMenuSectionAction + | DeactivateMenuSectionAction + | ToggleActiveMenuSectionAction +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/menu/menu.component.spec.ts b/src/app/shared/menu/menu.component.spec.ts new file mode 100644 index 0000000000..52cdebbe91 --- /dev/null +++ b/src/app/shared/menu/menu.component.spec.ts @@ -0,0 +1,90 @@ +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { MenuService } from './menu.service'; +import { MenuComponent } from './menu.component'; +import { MenuServiceStub } from '../testing/menu-service-stub'; +import { of as observableOf } from 'rxjs'; +import { MenuSection } from './menu.reducer'; + +describe('MenuComponent', () => { + let comp: MenuComponent; + let fixture: ComponentFixture; + let menuService: MenuService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule], + declarations: [MenuComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: MenuService, useClass: MenuServiceStub }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MenuComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MenuComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + menuService = (comp as any).menuService; + spyOn(comp as any, 'getSectionDataInjector').and.returnValue(MenuSection); + spyOn(comp as any, 'getSectionComponent').and.returnValue(observableOf({})); + fixture.detectChanges(); + }); + + describe('toggle', () => { + beforeEach(() => { + spyOn(menuService, 'toggleMenu'); + comp.toggle(new Event('click')); + }); + it('should trigger the toggleMenu function on the menu service', () => { + expect(menuService.toggleMenu).toHaveBeenCalledWith(comp.menuID); + }) + }); + + describe('expand', () => { + beforeEach(() => { + spyOn(menuService, 'expandMenu'); + comp.expand(new Event('click')); + }); + it('should trigger the expandMenu function on the menu service', () => { + expect(menuService.expandMenu).toHaveBeenCalledWith(comp.menuID); + }) + }); + + describe('collapse', () => { + beforeEach(() => { + spyOn(menuService, 'collapseMenu'); + comp.collapse(new Event('click')); + }); + it('should trigger the collapseMenu function on the menu service', () => { + expect(menuService.collapseMenu).toHaveBeenCalledWith(comp.menuID); + }) + }); + + describe('expandPreview', () => { + it('should trigger the expandPreview function on the menu service after 100ms', fakeAsync(() => { + spyOn(menuService, 'expandMenuPreview'); + comp.expandPreview(new Event('click')); + tick(99); + expect(menuService.expandMenuPreview).not.toHaveBeenCalled(); + tick(1); + expect(menuService.expandMenuPreview).toHaveBeenCalledWith(comp.menuID); + })) + }); + + describe('collapsePreview', () => { + it('should trigger the collapsePreview function on the menu service after 400ms', fakeAsync(() => { + spyOn(menuService, 'collapseMenuPreview'); + comp.collapsePreview(new Event('click')); + tick(399); + expect(menuService.collapseMenuPreview).not.toHaveBeenCalled(); + tick(1); + expect(menuService.collapseMenuPreview).toHaveBeenCalledWith(comp.menuID); + })) + }); +}); diff --git a/src/app/shared/menu/menu.component.ts b/src/app/shared/menu/menu.component.ts new file mode 100644 index 0000000000..763da86c4b --- /dev/null +++ b/src/app/shared/menu/menu.component.ts @@ -0,0 +1,168 @@ +import { ChangeDetectionStrategy, Component, Injector, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { MenuService } from '../../shared/menu/menu.service'; +import { MenuID } from '../../shared/menu/initial-menus-state'; +import { MenuSection } from '../../shared/menu/menu.reducer'; +import { first, map } from 'rxjs/operators'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { hasValue } from '../empty.util'; +import { MenuSectionComponent } from './menu-section/menu-section.component'; +import { getComponentForMenu } from './menu-section.decorator'; +import Timer = NodeJS.Timer; + +/** + * A basic implementation of a MenuComponent + */ +@Component({ + selector: 'ds-menu', + template: '' +}) +export class MenuComponent implements OnInit { + /** + * The ID of the Menu (See MenuID) + */ + menuID: MenuID; + + /** + * Observable that emits whether or not this menu is currently collapsed + */ + menuCollapsed: Observable; + + /** + * Observable that emits whether or not this menu's preview is currently collapsed + */ + menuPreviewCollapsed: Observable; + + /** + * Observable that emits whether or not this menu is currently visible + */ + menuVisible: Observable; + + /** + * List of top level sections in this Menu + */ + sections: Observable; + + /** + * List of Injectors for each dynamically rendered menu section + */ + sectionInjectors: Map = new Map(); + + /** + * List of child Components for each dynamically rendered menu section + */ + sectionComponents: Map> = new Map>(); + + /** + * Prevent unnecessary rerendering + */ + changeDetection: ChangeDetectionStrategy.OnPush; + + /** + * Timer to briefly delay the sidebar preview from opening or closing + */ + private previewTimer: Timer; + + constructor(protected menuService: MenuService, protected injector: Injector) { + } + + /** + * Sets all instance variables to their initial values + */ + ngOnInit(): void { + this.menuCollapsed = this.menuService.isMenuCollapsed(this.menuID); + this.menuPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID); + this.menuVisible = this.menuService.isMenuVisible(this.menuID); + this.sections = this.menuService.getMenuTopSections(this.menuID).pipe(first()); + this.sections.subscribe((sections: MenuSection[]) => { + sections.forEach((section: MenuSection) => { + this.sectionInjectors.set(section.id, this.getSectionDataInjector(section)); + this.getSectionComponent(section).pipe(first()).subscribe((constr) => this.sectionComponents.set(section.id, constr)); + }) + }) + } + + /** + * Collapse this menu when it's currently expanded, expand it when its currently collapsed + * @param {Event} event The user event that triggered this method + */ + toggle(event: Event) { + event.preventDefault(); + this.menuService.toggleMenu(this.menuID); + } + + /** + * Expand this menu + * @param {Event} event The user event that triggered this method + */ + expand(event: Event) { + event.preventDefault(); + this.menuService.expandMenu(this.menuID); + } + + /** + * Collapse this menu + * @param {Event} event The user event that triggered this method + */ + collapse(event: Event) { + event.preventDefault(); + this.menuService.collapseMenu(this.menuID); + } + + /** + * Expand this menu's preview + * @param {Event} event The user event that triggered this method + */ + expandPreview(event: Event) { + event.preventDefault(); + this.previewToggleDebounce(() => this.menuService.expandMenuPreview(this.menuID), 100); + } + + /** + * Collapse this menu's preview + * @param {Event} event The user event that triggered this method + */ + collapsePreview(event: Event) { + event.preventDefault(); + this.previewToggleDebounce(() => this.menuService.collapseMenuPreview(this.menuID), 400); + } + + /** + * delay the handler function by the given amount of time + * + * @param {Function} handler The function to delay + * @param {number} ms The amount of ms to delay the handler function by + */ + private previewToggleDebounce(handler: () => void, ms: number): void { + if (hasValue(this.previewTimer)) { + clearTimeout(this.previewTimer); + } + this.previewTimer = setTimeout(handler, ms); + } + + /** + * Retrieve the component for a given MenuSection object + * @param {MenuSection} section The given MenuSection + * @returns {Observable>} Emits the constructor of the Component that should be used to render this object + */ + private getSectionComponent(section: MenuSection): Observable> { + return this.menuService.hasSubSections(this.menuID, section.id).pipe( + map((expandable: boolean) => { + return getComponentForMenu(this.menuID, expandable); + } + ), + ); + } + + /** + * Retrieve the Injector for a given MenuSection object + * @param {MenuSection} section The given MenuSection + * @returns {Injector} The Injector that injects the data for this menu section into the section's component + */ + private getSectionDataInjector(section: MenuSection) { + return Injector.create({ + providers: [{ provide: 'sectionDataProvider', useFactory: () => (section), deps: [] }], + parent: this.injector + }); + } +} diff --git a/src/app/shared/menu/menu.module.ts b/src/app/shared/menu/menu.module.ts new file mode 100644 index 0000000000..7e900d18e6 --- /dev/null +++ b/src/app/shared/menu/menu.module.ts @@ -0,0 +1,57 @@ +import { MenuSectionComponent } from './menu-section/menu-section.component'; +import { MenuComponent } from './menu.component'; +import { NgModule } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule } from '@angular/router'; +import { LinkMenuItemComponent } from './menu-item/link-menu-item.component'; +import { TextMenuItemComponent } from './menu-item/text-menu-item.component'; +import { OnClickMenuItemComponent } from './menu-item/onclick-menu-item.component'; + +const COMPONENTS = [ + MenuSectionComponent, + MenuComponent, + LinkMenuItemComponent, + TextMenuItemComponent, + OnClickMenuItemComponent +]; + +const ENTRY_COMPONENTS = [ + LinkMenuItemComponent, + TextMenuItemComponent, + OnClickMenuItemComponent +]; + +const MODULES = [ + TranslateModule, + RouterModule +]; +const PROVIDERS = [ + +]; + +@NgModule({ + imports: [ + ...MODULES + ], + declarations: [ + ...COMPONENTS, + ...ENTRY_COMPONENTS, + ], + providers: [ + ...PROVIDERS + ], + exports: [ + ...COMPONENTS, + ...MODULES + ], + entryComponents: [ + ...ENTRY_COMPONENTS + ] +}) + +/** + * This module handles all components, providers and modules that are needed for the menu + */ +export class MenuModule { + +} diff --git a/src/app/shared/menu/menu.reducer.spec.ts b/src/app/shared/menu/menu.reducer.spec.ts new file mode 100644 index 0000000000..ac56bed1f0 --- /dev/null +++ b/src/app/shared/menu/menu.reducer.spec.ts @@ -0,0 +1,467 @@ +import * as deepFreeze from 'deep-freeze'; +import { + ActivateMenuSectionAction, + AddMenuSectionAction, + CollapseMenuAction, + CollapseMenuPreviewAction, + DeactivateMenuSectionAction, + ExpandMenuAction, + ExpandMenuPreviewAction, + HideMenuAction, + HideMenuSectionAction, + RemoveMenuSectionAction, + ShowMenuAction, + ShowMenuSectionAction, + ToggleActiveMenuSectionAction, + ToggleMenuAction +} from './menu.actions'; +import { MenuSectionIndex, menusReducer } from './menu.reducer'; +import { initialMenusState, MenuID } from './initial-menus-state'; + +let visibleSection1; +let dummyState; +const menuID = MenuID.ADMIN; +const topSectionID = 'new'; + +class NullAction extends CollapseMenuAction { + type = null; + + constructor() { + super(undefined); + } +} + +describe('menusReducer', () => { + beforeEach(() => { + visibleSection1 = { + id: 'section', + parentID: 'new', + visible: true, + active: false, + index: -1, + }; + + dummyState = { + [MenuID.ADMIN]: { + id: MenuID.ADMIN, + collapsed: true, + previewCollapsed: true, + visible: true, + sections: { + [topSectionID]: { + id: topSectionID, + active: false, + visible: true, + model: { + type: 0, + text: 'menu.section.new' + }, + icon: 'plus-circle', + index: 0 + }, + new_item: { + id: 'new_item', + parentID: 'new', + active: false, + visible: true, + model: { + type: 1, + text: 'menu.section.new_item', + link: '/items/submission' + } + }, + new_community: { + id: 'new_community', + parentID: 'new', + active: false, + visible: true, + model: { + type: 1, + text: 'menu.section.new_community', + link: '/communities/submission' + } + }, + access_control: { + id: 'access_control', + active: false, + visible: true, + model: { + type: 0, + text: 'menu.section.access_control' + }, + icon: 'key', + index: 4 + }, + access_control_people: { + id: 'access_control_people', + parentID: 'access_control', + active: false, + visible: true, + model: { + type: 1, + text: 'menu.section.access_control_people', + link: '#' + } + }, + access_control_groups: { + id: 'access_control_groups', + parentID: 'access_control', + active: false, + visible: true, + model: { + type: 1, + text: 'menu.section.access_control_groups', + link: '#' + } + }, + new_collection: { + id: 'new_collection', + parentID: 'new', + active: false, + visible: true, + model: { + type: 1, + text: 'menu.section.new_collection', + link: '/collections/submission' + } + } + }, + sectionToSubsectionIndex: { + access_control: [ + 'access_control_people', + 'access_control_groups', + ], + new: [ + 'new_collection', + 'new_item', + 'new_community' + ] + } + } + } + }); + + it('should return the current state when no valid actions have been made', () => { + const state = dummyState; + const action = new NullAction(); + const newState = menusReducer(state, action); + + expect(newState).toEqual(state); + }); + + it('should start with the initialMenusState', () => { + const state = initialMenusState; + const action = new NullAction(); + const initialState = menusReducer(undefined, action); + + // The search filter starts collapsed + expect(initialState).toEqual(state); + }); + + it('should set collapsed to true for the correct menu in response to the COLLAPSE_MENU action', () => { + dummyState[MenuID.ADMIN].collapsed = false; + const state = dummyState; + const action = new CollapseMenuAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].collapsed).toEqual(true); + }); + + it('should perform the COLLAPSE_MENU action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].collapsed = false; + const state = dummyState; + deepFreeze([state]); + + const action = new CollapseMenuAction(menuID); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set collapsed to false for the correct menu in response to the EXPAND_MENU action', () => { + dummyState[MenuID.ADMIN].collapsed = true; + const state = dummyState; + const action = new ExpandMenuAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].collapsed).toEqual(false); + }); + + it('should perform the EXPAND_MENU action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].collapsed = true; + const state = dummyState; + deepFreeze([state]); + + const action = new ExpandMenuAction(menuID); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set collapsed to false for the correct menu in response to the TOGGLE_MENU action when collapsed is true', () => { + dummyState[MenuID.ADMIN].collapsed = true; + const state = dummyState; + const action = new ToggleMenuAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].collapsed).toEqual(false); + }); + + it('should set collapsed to true for the correct menu in response to the TOGGLE_MENU action when collapsed is false', () => { + dummyState[MenuID.ADMIN].collapsed = true; + const state = dummyState; + const action = new ToggleMenuAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].collapsed).toEqual(false); + }); + + it('should perform the TOGGLE_MENU action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].collapsed = true; + const state = dummyState; + deepFreeze([state]); + + const action = new ToggleMenuAction(menuID); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set previewCollapsed to true for the correct menu in response to the COLLAPSE_MENU_PREVIEW action', () => { + dummyState[MenuID.ADMIN].previewCollapsed = false; + const state = dummyState; + const action = new CollapseMenuPreviewAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].previewCollapsed).toEqual(true); + }); + + it('should perform the COLLAPSE_MENU_PREVIEW action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].previewCollapsed = false; + const state = dummyState; + deepFreeze([state]); + + const action = new CollapseMenuPreviewAction(menuID); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set previewCollapsed to false for the correct menu in response to the EXPAND_MENU_PREVIEW action', () => { + dummyState[MenuID.ADMIN].previewCollapsed = true; + const state = dummyState; + const action = new ExpandMenuPreviewAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].previewCollapsed).toEqual(false); + }); + + it('should perform the EXPAND_MENU_PREVIEW action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].previewCollapsed = true; + const state = dummyState; + deepFreeze([state]); + + const action = new ExpandMenuPreviewAction(menuID); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set visible to true for the correct menu in response to the SHOW_MENU action', () => { + dummyState[MenuID.ADMIN].visible = false; + const state = dummyState; + const action = new ShowMenuAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].visible).toEqual(true); + }); + + it('should perform the SHOW_MENU action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].visible = false; + const state = dummyState; + deepFreeze([state]); + + const action = new ShowMenuAction(menuID); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set previewCollapsed to false for the correct menu in response to the HIDE_MENU action', () => { + dummyState[MenuID.ADMIN].visible = true; + const state = dummyState; + const action = new HideMenuAction(menuID); + const newState = menusReducer(state, action); + + expect(newState[menuID].visible).toEqual(false); + }); + + it('should perform the HIDE_MENU action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].visible = true; + const state = dummyState; + deepFreeze([state]); + + const action = new HideMenuAction(menuID); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set add a new section for the correct menu in response to the ADD_SECTION action', () => { + const state = dummyState; + const action = new AddMenuSectionAction(menuID, visibleSection1); + const newState = menusReducer(state, action); + expect(Object.values(newState[menuID].sections)).toContain(visibleSection1); + }); + + it('should set add a new section in the right place according to the index for the correct menu in response to the ADD_SECTION action', () => { + const state = dummyState; + const action = new AddMenuSectionAction(menuID, visibleSection1); + const newState = menusReducer(state, action); + expect(Object.values(newState[menuID].sections)[0]).toEqual(visibleSection1); + }); + + it('should add the new section to the sectionToSubsectionIndex when it has a parentID in response to the ADD_SECTION action', () => { + const state = dummyState; + const action = new AddMenuSectionAction(menuID, visibleSection1); + const newState = menusReducer(state, action); + expect(newState[menuID].sectionToSubsectionIndex[visibleSection1.parentID]).toContain(visibleSection1.id) + }); + + it('should perform the ADD_SECTION action without affecting the previous state', () => { + const state = dummyState; + deepFreeze([state]); + + const action = new AddMenuSectionAction(menuID, visibleSection1); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should remove a section for the correct menu in response to the REMOVE_SECTION action', () => { + const sectionID = Object.keys(dummyState[menuID].sections)[0]; + const state = dummyState; + const action = new RemoveMenuSectionAction(menuID, sectionID); + const newState = menusReducer(state, action); + expect(Object.keys(newState[menuID].sections)).not.toContain(sectionID); + }); + + it('should remove a section for the correct menu from the sectionToSubsectionIndex in response to the REMOVE_SECTION action', () => { + const index: MenuSectionIndex = dummyState[menuID].sectionToSubsectionIndex; + const parentID: string = Object.keys(index)[0]; + const childID: string = index[parentID][0]; + const state = dummyState; + const action = new RemoveMenuSectionAction(menuID, childID); + const newState = menusReducer(state, action); + expect(newState[menuID].sectionToSubsectionIndex[parentID]).not.toContain(childID); + }); + + it('should set active to true for the correct menu section in response to the ACTIVATE_SECTION action', () => { + dummyState[menuID].sections[topSectionID].active = false; + const state = dummyState; + const action = new ActivateMenuSectionAction(menuID, topSectionID); + const newState = menusReducer(state, action); + + expect(newState[menuID].sections[topSectionID].active).toEqual(true); + }); + + it('should perform the ACTIVATE_SECTION action without affecting the previous state', () => { + dummyState[menuID].sections[topSectionID].active = false; + const state = dummyState; + deepFreeze([state]); + + const action = new ActivateMenuSectionAction(menuID, topSectionID); + menusReducer(state, action); + }); + + it('should set active to false for the correct menu section in response to the DEACTIVATE_SECTION action', () => { + dummyState[menuID].sections[topSectionID].active = true; + const state = dummyState; + const action = new DeactivateMenuSectionAction(menuID, topSectionID); + const newState = menusReducer(state, action); + + expect(newState[menuID].sections[topSectionID].active).toEqual(false); + }); + + it('should perform the DEACTIVATE_SECTION action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].sections[topSectionID].active = false; + const state = dummyState; + deepFreeze([state]); + + const action = new DeactivateMenuSectionAction(menuID, topSectionID); + menusReducer(state, action); + }); + + it('should set active to false for the correct menu in response to the TOGGLE_ACTIVE_SECTION action when active is true', () => { + dummyState[menuID].sections[topSectionID].active = true; + const state = dummyState; + const action = new ToggleActiveMenuSectionAction(menuID, topSectionID); + const newState = menusReducer(state, action); + + expect(newState[menuID].sections[topSectionID].active).toEqual(false); + }); + + it('should set collapsed to true for the correct menu in response to the TOGGLE_ACTIVE_SECTION action when active is false', () => { + dummyState[menuID].sections[topSectionID].active = false; + const state = dummyState; + const action = new ToggleActiveMenuSectionAction(menuID, topSectionID); + const newState = menusReducer(state, action); + + expect(newState[menuID].sections[topSectionID].active).toEqual(true); + }); + + it('should perform the TOGGLE_ACTIVE_SECTION action without affecting the previous state', () => { + dummyState[menuID].sections[topSectionID].active = true; + const state = dummyState; + const action = new ToggleActiveMenuSectionAction(menuID, topSectionID); + deepFreeze([state]); + menusReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + }); + + it('should set visible to true for the correct menu section in response to the SHOW_SECTION action', () => { + dummyState[menuID].sections[topSectionID].visible = false; + const state = dummyState; + const action = new ShowMenuSectionAction(menuID, topSectionID); + const newState = menusReducer(state, action); + + expect(newState[menuID].sections[topSectionID].visible).toEqual(true); + }); + + it('should perform the SHOW_SECTION action without affecting the previous state', () => { + dummyState[menuID].sections[topSectionID].visible = false; + const state = dummyState; + deepFreeze([state]); + + const action = new ShowMenuSectionAction(menuID, topSectionID); + menusReducer(state, action); + }); + + it('should set visible to false for the correct menu section in response to the HIDE_SECTION action', () => { + dummyState[menuID].sections[topSectionID].visible = true; + const state = dummyState; + const action = new HideMenuSectionAction(menuID, topSectionID); + const newState = menusReducer(state, action); + + expect(newState[menuID].sections[topSectionID].visible).toEqual(false); + }); + + it('should perform the HIDE_SECTION action without affecting the previous state', () => { + dummyState[MenuID.ADMIN].sections[topSectionID].visible = false; + const state = dummyState; + deepFreeze([state]); + + const action = new HideMenuSectionAction(menuID, topSectionID); + menusReducer(state, action); + }); +}); diff --git a/src/app/shared/menu/menu.reducer.ts b/src/app/shared/menu/menu.reducer.ts new file mode 100644 index 0000000000..078343a990 --- /dev/null +++ b/src/app/shared/menu/menu.reducer.ts @@ -0,0 +1,299 @@ +import { + ActivateMenuSectionAction, + AddMenuSectionAction, + DeactivateMenuSectionAction, + HideMenuSectionAction, + MenuAction, + MenuActionTypes, + MenuSectionAction, + RemoveMenuSectionAction, + ShowMenuSectionAction, + ToggleActiveMenuSectionAction +} from './menu.actions'; +import { initialMenusState, MenuID } from './initial-menus-state'; +import { hasValue } from '../empty.util'; +import { MenuItemModel } from './menu-item/models/menu-item.model'; + +/** + * Represents the state of all menus in the store + */ +export type MenusState = { + [id in MenuID]: MenuState; +} + +/** + * Represents the state of a single menu in the store + */ +export interface MenuState { + id: MenuID; + collapsed: boolean + previewCollapsed: boolean; + visible: boolean; + sections: MenuSections + sectionToSubsectionIndex: MenuSectionIndex; +} + +/** + * Represents the a mapping of all sections to their subsections for a menu in the store + */ +export interface MenuSectionIndex { + [id: string]: string[] +} + +/** + * Represents the state of all menu sections in the store + */ +export interface MenuSections { + [id: string]: MenuSection; +} + +/** + * Represents the state of a single menu section in the store + */ +export class MenuSection { + id: string; + parentID?: string; + visible: boolean; + active: boolean; + model: MenuItemModel; + index?: number; + icon?: string; +} + +/** + * Reducer that handles MenuActions to update the MenusState + * @param {MenusState} state The initial MenusState + * @param {MenuAction} action The Action to be performed on the state + * @returns {MenusState} The new, reducer MenusState + */ +export function menusReducer(state: MenusState = initialMenusState, action: MenuAction): MenusState { + const menuState: MenuState = state[action.menuID]; + switch (action.type) { + case MenuActionTypes.COLLAPSE_MENU: { + const newMenuState = Object.assign({}, menuState, { collapsed: true }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); + } + case MenuActionTypes.EXPAND_MENU: { + const newMenuState = Object.assign({}, menuState, { collapsed: false }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); + } + case MenuActionTypes.COLLAPSE_MENU_PREVIEW: { + const newMenuState = Object.assign({}, menuState, { previewCollapsed: true }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); + } + case MenuActionTypes.EXPAND_MENU_PREVIEW: { + const newMenuState = Object.assign({}, menuState, { previewCollapsed: false }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); + } + case MenuActionTypes.TOGGLE_MENU: { + const newMenuState = Object.assign({}, menuState, { collapsed: !menuState.collapsed }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); + } + case MenuActionTypes.SHOW_MENU: { + const newMenuState = Object.assign({}, menuState, { visible: true }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); + } + case MenuActionTypes.HIDE_MENU: { + const newMenuState = Object.assign({}, menuState, { visible: false }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); + } + case MenuActionTypes.ADD_SECTION: { + return addSection(state, action as AddMenuSectionAction); + } + case MenuActionTypes.REMOVE_SECTION: { + return removeSection(state, action as RemoveMenuSectionAction); + } + case MenuActionTypes.ACTIVATE_SECTION: { + return activateSection(state, action as ActivateMenuSectionAction); + } + case MenuActionTypes.DEACTIVATE_SECTION: { + return deactivateSection(state, action as DeactivateMenuSectionAction); + } + case MenuActionTypes.TOGGLE_ACTIVE_SECTION: { + return toggleActiveSection(state, action as ToggleActiveMenuSectionAction); + } + case MenuActionTypes.HIDE_SECTION: { + return hideSection(state, action as HideMenuSectionAction); + } + case MenuActionTypes.SHOW_SECTION: { + return showSection(state, action as ShowMenuSectionAction); + } + + default: { + return state; + } + } +} + +/** + * Add a section the a certain menu + * @param {MenusState} state The initial state + * @param {AddMenuSectionAction} action Action containing the new section and the menu's ID + * @returns {MenusState} The new reduced state + */ +function addSection(state: MenusState, action: AddMenuSectionAction) { + // let newState = addToIndex(state, action.section, action.menuID); + const newState = putSectionState(state, action, action.section); + return reorderSections(newState, action) +} + +/** + * Reorder all sections based on their index field + * @param {MenusState} state The initial state + * @param {MenuSectionAction} action Action containing the menu ID of the menu that is to be reordered + * @returns {MenusState} The new reduced state + */ +function reorderSections(state: MenusState, action: MenuSectionAction) { + const menuState: MenuState = state[action.menuID]; + const newSectionState: MenuSections = {}; + const newSectionIndexState: MenuSectionIndex = {}; + + Object.values(menuState.sections).sort((sectionA: MenuSection, sectionB: MenuSection) => { + const indexA = sectionA.index || 0; + const indexB = sectionB.index || 0; + return indexA - indexB; + }).forEach((section: MenuSection) => { + newSectionState[section.id] = section; + if (hasValue(section.parentID)) { + const parentIndex = hasValue(newSectionIndexState[section.parentID]) ? newSectionIndexState[section.parentID] : []; + newSectionIndexState[section.parentID] = [...parentIndex, section.id]; + } + }); + const newMenuState = Object.assign({}, menuState, { + sections: newSectionState, + sectionToSubsectionIndex: newSectionIndexState + }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); +} + +/** + * Remove a section from a certain menu + * @param {MenusState} state The initial state + * @param {RemoveMenuSectionAction} action Action containing the section ID and menu ID of the section that should be removed + * @returns {MenusState} The new reduced state + */ +function removeSection(state: MenusState, action: RemoveMenuSectionAction) { + const menuState: MenuState = state[action.menuID]; + const id = action.id; + const newState = removeFromIndex(state, menuState.sections[action.id], action.menuID); + const newMenuState = Object.assign({}, newState[action.menuID]); + delete newMenuState.sections[id]; + return Object.assign({}, newState, { [action.menuID]: newMenuState }); +} + +/** + * Remove a section from the index of a certain menu + * @param {MenusState} state The initial state + * @param {MenuSection} action The MenuSection of which the ID should be removed from the index + * @param {MenuID} action The Menu ID to which the section belonged + * @returns {MenusState} The new reduced state + */ +function removeFromIndex(state: MenusState, section: MenuSection, menuID: MenuID) { + const sectionID = section.id; + const parentID = section.parentID; + if (hasValue(parentID)) { + const menuState: MenuState = state[menuID]; + const index = menuState.sectionToSubsectionIndex; + const parentIndex = hasValue(index[parentID]) ? index[parentID] : []; + const newIndex = Object.assign({}, index, { [parentID]: parentIndex.filter((id) => id !== sectionID) }); + const newMenuState = Object.assign({}, menuState, { sectionToSubsectionIndex: newIndex }); + return Object.assign({}, state, { [menuID]: newMenuState }); + } + return state; +} + +/** + * Hide a certain section + * @param {MenusState} state The initial state + * @param {HideMenuSectionAction} action Action containing data to identify the section to be updated + * @returns {MenusState} The new reduced state + */ +function hideSection(state: MenusState, action: HideMenuSectionAction) { + return updateSectionState(state, action, { visible: false }); +} + +/** + * Show a certain section + * @param {MenusState} state The initial state + * @param {ShowMenuSectionAction} action Action containing data to identify the section to be updated + * @returns {MenusState} The new reduced state + */ +function showSection(state: MenusState, action: ShowMenuSectionAction) { + return updateSectionState(state, action, { visible: true }); +} + +/** + * Deactivate a certain section + * @param {MenusState} state The initial state + * @param {DeactivateMenuSectionAction} action Action containing data to identify the section to be updated + * @returns {MenusState} The new reduced state + */ +function deactivateSection(state: MenusState, action: DeactivateMenuSectionAction) { + const sectionState: MenuSection = state[action.menuID].sections[action.id]; + if (hasValue(sectionState)) { + return updateSectionState(state, action, { active: false }); + } +} + +/** + * Activate a certain section + * @param {MenusState} state The initial state + * @param {DeactivateMenuSectionAction} action Action containing data to identify the section to be updated + * @returns {MenusState} The new reduced state + */ +function activateSection(state: MenusState, action: ActivateMenuSectionAction) { + const sectionState: MenuSection = state[action.menuID].sections[action.id]; + if (hasValue(sectionState)) { + return updateSectionState(state, action, { active: true }); + } +} + +/** + * Deactivate a certain section when it's currently active, activate a certain section when it's currently inactive + * @param {MenusState} state The initial state + * @param {DeactivateMenuSectionAction} action Action containing data to identify the section to be updated + * @returns {MenusState} The new reduced state + */ +function toggleActiveSection(state: MenusState, action: ToggleActiveMenuSectionAction) { + const sectionState: MenuSection = state[action.menuID].sections[action.id]; + if (hasValue(sectionState)) { + return updateSectionState(state, action, { active: !sectionState.active }); + } + return state; +} + +/** + * Add or replace a section in the state + * @param {MenusState} state The initial state + * @param {MenuAction} action The action which contains the menu ID of the menu of which the state is to be updated + * @param {MenuSection} section The section that will be added or replaced in the state + * @returns {MenusState} The new reduced state + */ +function putSectionState(state: MenusState, action: MenuAction, section: MenuSection): MenusState { + const menuState: MenuState = state[action.menuID]; + const newSections = Object.assign({}, menuState.sections, { + [section.id]: section + }); + const newMenuState = Object.assign({}, menuState, { + sections: newSections + }); + return Object.assign({}, state, { [action.menuID]: newMenuState }); +} + +/** + * Update a section + * @param {MenusState} state The initial state + * @param {MenuSectionAction} action The action containing the menu ID and section ID + * @param {any} update A partial section that represents the part that should be updated in an existing section + * @returns {MenusState} The new reduced state + */ +function updateSectionState(state: MenusState, action: MenuSectionAction, update: any): MenusState { + const menuState: MenuState = state[action.menuID]; + const sectionState = menuState.sections[action.id]; + if (hasValue(sectionState)) { + const newTopSection = Object.assign({}, sectionState, update); + return putSectionState(state, action, newTopSection); + + } + return state; +} diff --git a/src/app/shared/menu/menu.service.spec.ts b/src/app/shared/menu/menu.service.spec.ts new file mode 100644 index 0000000000..b7f955b257 --- /dev/null +++ b/src/app/shared/menu/menu.service.spec.ts @@ -0,0 +1,396 @@ +import * as ngrx from '@ngrx/store'; +import { Store } from '@ngrx/store'; +import { async, TestBed } from '@angular/core/testing'; +import { MenuService } from './menu.service'; +import { cold, hot } from 'jasmine-marbles'; +import { MenuID } from './initial-menus-state'; +import { of as observableOf } from 'rxjs'; +import { + ActivateMenuSectionAction, + AddMenuSectionAction, + CollapseMenuAction, CollapseMenuPreviewAction, DeactivateMenuSectionAction, + ExpandMenuAction, ExpandMenuPreviewAction, HideMenuAction, + RemoveMenuSectionAction, ShowMenuAction, ToggleActiveMenuSectionAction, ToggleMenuAction +} from './menu.actions'; + +describe('MenuService', () => { + let service: MenuService; + let selectSpy; + const store = Object.assign(observableOf({}), { + dispatch: () => {/***/ + } + }) as any; + const fakeMenu = { + id: MenuID.ADMIN, + collapsed: true, + visible: false, + previewCollapsed: true + } as any; + const visibleSection1 = { + id: 'section', + visible: true, + active: false + }; + const visibleSection2 = { + id: 'section_2', + visible: true + }; + const hiddenSection3 = { + id: 'section_3', + visible: false + }; + const subSection4 = { + id: 'section_4', + visible: true, + parentID: 'section1' + }; + + const topSections = { + section: visibleSection1, + section_2: visibleSection2, + section_3: hiddenSection3, + section_4: subSection4 + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: Store, useValue: store }, + { provide: MenuService, useValue: service } + ] + }).compileComponents(); + })); + + beforeEach(() => { + service = new MenuService(store); + selectSpy = spyOnProperty(ngrx, 'select'); + spyOn(store, 'dispatch'); + }); + + describe('getMenu', () => { + beforeEach(() => { + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: fakeMenu + } + ); + }; + }); + }); + it('should return the menu', () => { + + const result = service.getMenu(MenuID.ADMIN); + const expected = cold('b', { + b: fakeMenu + }); + + expect(result).toBeObservable(expected); + }) + }); + + describe('getMenuTopSections', () => { + beforeEach(() => { + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: topSections + } + ); + }; + }); + }); + it('should return only the visible top MenuSections when mustBeVisible is true', () => { + + const result = service.getMenuTopSections(MenuID.ADMIN); + const expected = cold('b', { + b: [visibleSection1, visibleSection2] + }); + + expect(result).toBeObservable(expected); + }); + + it('should return only the all top MenuSections when mustBeVisible is false', () => { + + const result = service.getMenuTopSections(MenuID.ADMIN, false); + const expected = cold('b', { + b: [visibleSection1, visibleSection2, hiddenSection3] + }); + + expect(result).toBeObservable(expected); + }) + }); + + describe('getSubSectionsByParentID', () => { + describe('when the subsection list is not empty', () => { + + beforeEach(() => { + spyOn(service, 'getMenuSection').and.returnValue(observableOf(visibleSection1)); + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: ['id1', 'id2'] + } + ); + }; + }); + }); + it('should return the MenuSections with the given parentID', () => { + + const result = service.getSubSectionsByParentID(MenuID.ADMIN, 'fakeId'); + const expected = cold('b', { + b: [visibleSection1, visibleSection1] + }); + + expect(result).toBeObservable(expected); + }) + }); + describe('when the subsection list is undefined', () => { + + beforeEach(() => { + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: undefined + } + ); + }; + }); + }); + it('should return an observable that emits nothing', () => { + + const result = service.getSubSectionsByParentID(MenuID.ADMIN, 'fakeId'); + const expected = cold(''); + + expect(result).toBeObservable(expected); + }) + }); + }); + + describe('hasSubSections', () => { + describe('when the subsection list is not empty', () => { + beforeEach(() => { + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: ['id1', 'id2'] + } + ); + }; + }); + }); + it('should return true', () => { + + const result = service.hasSubSections(MenuID.ADMIN, 'fakeId'); + const expected = cold('b', { + b: true + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('when the subsection list is empty', () => { + beforeEach(() => { + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: [] + } + ); + }; + }); + }); + it('should return false', () => { + + const result = service.hasSubSections(MenuID.ADMIN, 'fakeId'); + const expected = cold('b', { + b: false + }); + + expect(result).toBeObservable(expected); + }); + }) + }); + + describe('getMenuSection', () => { + beforeEach(() => { + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: hiddenSection3 + } + ); + }; + }); + }); + it('should return false', () => { + + const result = service.getMenuSection(MenuID.ADMIN, 'fakeId'); + const expected = cold('b', { + b: hiddenSection3 + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('isMenuCollapsed', () => { + beforeEach(() => { + spyOn(service, 'getMenu').and.returnValue(observableOf(fakeMenu)); + }); + it('should return true when the menu is collapsed', () => { + + const result = service.isMenuCollapsed(MenuID.ADMIN); + const expected = cold('(b|)', { + b: fakeMenu.collapsed + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('isMenuPreviewCollapsed', () => { + beforeEach(() => { + spyOn(service, 'getMenu').and.returnValue(observableOf(fakeMenu)); + }); + it('should return true when the menu\'s preview is collapsed', () => { + + const result = service.isMenuPreviewCollapsed(MenuID.ADMIN); + const expected = cold('(b|)', { + b: fakeMenu.previewCollapsed + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('isMenuVisible', () => { + beforeEach(() => { + spyOn(service, 'getMenu').and.returnValue(observableOf(fakeMenu)); + + }); + it('should return false when the menu is hidden', () => { + + const result = service.isMenuVisible(MenuID.ADMIN); + const expected = cold('(b|)', { + b: fakeMenu.visible + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('isSectionActive', () => { + beforeEach(() => { + spyOn(service, 'getMenuSection').and.returnValue(observableOf(visibleSection1)); + }); + + it('should return false when the section is not active', () => { + const result = service.isSectionActive(MenuID.ADMIN, 'fakeID'); + const expected = cold('(b|)', { + b: visibleSection1.active + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('isSectionVisible', () => { + beforeEach(() => { + spyOn(service, 'getMenuSection').and.returnValue(observableOf(hiddenSection3)); + }); + + it('should return false when the section is hidden', () => { + const result = service.isSectionVisible(MenuID.ADMIN, 'fakeID'); + const expected = cold('(b|)', { + b: hiddenSection3.visible + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('addSection', () => { + it('should dispatch an AddMenuSectionAction with the correct arguments', () => { + service.addSection(MenuID.ADMIN, visibleSection1 as any); + expect(store.dispatch).toHaveBeenCalledWith(new AddMenuSectionAction(MenuID.ADMIN, visibleSection1 as any)); + }); + }); + + describe('removeSection', () => { + it('should dispatch an RemoveMenuSectionAction with the correct arguments', () => { + service.removeSection(MenuID.ADMIN, 'fakeID'); + expect(store.dispatch).toHaveBeenCalledWith(new RemoveMenuSectionAction(MenuID.ADMIN, 'fakeID')); + }); + }); + + describe('expandMenu', () => { + it('should dispatch an ExpandMenuAction with the correct arguments', () => { + service.expandMenu(MenuID.ADMIN); + expect(store.dispatch).toHaveBeenCalledWith(new ExpandMenuAction(MenuID.ADMIN)); + }); + }); + + describe('collapseMenu', () => { + it('should dispatch an CollapseMenuAction with the correct arguments', () => { + service.collapseMenu(MenuID.ADMIN); + expect(store.dispatch).toHaveBeenCalledWith(new CollapseMenuAction(MenuID.ADMIN)); + }); + }); + + describe('expandMenuPreview', () => { + it('should dispatch an ExpandMenuPreviewAction with the correct arguments', () => { + service.expandMenuPreview(MenuID.ADMIN); + expect(store.dispatch).toHaveBeenCalledWith(new ExpandMenuPreviewAction(MenuID.ADMIN)); + }); + }); + + describe('collapseMenuPreview', () => { + it('should dispatch an CollapseMenuPreviewAction with the correct arguments', () => { + service.collapseMenuPreview(MenuID.ADMIN); + expect(store.dispatch).toHaveBeenCalledWith(new CollapseMenuPreviewAction(MenuID.ADMIN)); + }); + }); + + describe('toggleMenu', () => { + it('should dispatch an ToggleMenuAction with the correct arguments', () => { + service.toggleMenu(MenuID.ADMIN); + expect(store.dispatch).toHaveBeenCalledWith(new ToggleMenuAction(MenuID.ADMIN)); + }); + }); + + describe('showMenu', () => { + it('should dispatch an ShowMenuAction with the correct arguments', () => { + service.showMenu(MenuID.ADMIN); + expect(store.dispatch).toHaveBeenCalledWith(new ShowMenuAction(MenuID.ADMIN)); + }); + }); + + describe('hideMenu', () => { + it('should dispatch an HideMenuAction with the correct arguments', () => { + service.hideMenu(MenuID.ADMIN); + expect(store.dispatch).toHaveBeenCalledWith(new HideMenuAction(MenuID.ADMIN)); + }); + }); + + describe('toggleActiveSection', () => { + it('should dispatch an ToggleActiveMenuSectionAction with the correct arguments', () => { + service.toggleActiveSection(MenuID.ADMIN, 'fakeID'); + expect(store.dispatch).toHaveBeenCalledWith(new ToggleActiveMenuSectionAction(MenuID.ADMIN, 'fakeID')); + }); + }); + + describe('activateSection', () => { + it('should dispatch an ActivateMenuSectionAction with the correct arguments', () => { + service.activateSection(MenuID.ADMIN, 'fakeID'); + expect(store.dispatch).toHaveBeenCalledWith(new ActivateMenuSectionAction(MenuID.ADMIN, 'fakeID')); + }); + }); + + describe('deactivateSection', () => { + it('should dispatch an DeactivateMenuSectionAction with the correct arguments', () => { + service.deactivateSection(MenuID.ADMIN, 'fakeID'); + expect(store.dispatch).toHaveBeenCalledWith(new DeactivateMenuSectionAction(MenuID.ADMIN, 'fakeID')); + }); + }); +}); diff --git a/src/app/shared/menu/menu.service.ts b/src/app/shared/menu/menu.service.ts new file mode 100644 index 0000000000..067de57aae --- /dev/null +++ b/src/app/shared/menu/menu.service.ts @@ -0,0 +1,275 @@ +import { Injectable } from '@angular/core'; +import { MemoizedSelector, select, Store } from '@ngrx/store'; +import { MenuSection, MenuSectionIndex, MenuSections, MenusState, MenuState } from './menu.reducer'; +import { AppState, keySelector } from '../../app.reducer'; +import { MenuID } from './initial-menus-state'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { + ActivateMenuSectionAction, + AddMenuSectionAction, + CollapseMenuAction, CollapseMenuPreviewAction, + DeactivateMenuSectionAction, + ExpandMenuAction, ExpandMenuPreviewAction, + HideMenuAction, + RemoveMenuSectionAction, + ShowMenuAction, + ToggleActiveMenuSectionAction, + ToggleMenuAction, +} from './menu.actions'; +import { hasNoValue, isNotEmpty } from '../empty.util'; +import { combineLatest as observableCombineLatest } from 'rxjs'; + +const menusStateSelector = (state) => state.menus; + +const menuByIDSelector = (menuID: MenuID): MemoizedSelector => { + return keySelector(menuID, menusStateSelector); +}; + +const menuSectionStateSelector = (state: MenuState) => state.sections; + +const menuSectionByIDSelector = (id: string): MemoizedSelector => { + return keySelector(id, menuSectionStateSelector); +}; + +const menuSectionIndexStateSelector = (state: MenuState) => state.sectionToSubsectionIndex; + +const getSubSectionsFromSectionSelector = (id: string): MemoizedSelector => { + return keySelector(id, menuSectionIndexStateSelector); +}; + +@Injectable() +export class MenuService { + + constructor(private store: Store) { + } + + /** + * Retrieve a menu's state by its ID + * @param {MenuID} id ID of the requested Menu + * @returns {Observable} Observable that emits the current state of the requested Menu + */ + getMenu(id: MenuID): Observable { + return this.store.pipe(select(menuByIDSelector(id))); + } + + /** + * Retrieve all top level sections of a certain menu + * @param {MenuID} menuID ID of the Menu + * @param {boolean} mustBeVisible True if you only want to request visible sections, false if you want to request all top level sections + * @returns {Observable} Observable that emits a list of MenuSections that are top sections of the given menu + */ + getMenuTopSections(menuID: MenuID, mustBeVisible = true): Observable { + return this.store.pipe( + select(menuByIDSelector(menuID)), + select(menuSectionStateSelector), + map((sections: MenuSections) => { + return Object.values(sections) + .filter((section: MenuSection) => hasNoValue(section.parentID)) + .filter((section: MenuSection) => !mustBeVisible || section.visible) + } + ) + ); + } + + /** + * Retrieve all sub level sections of a certain top section in a given menu + * @param {MenuID} menuID The ID of the menu + * @param {string} parentID The ID of the parent section + * @param {boolean} mustBeVisible True if you only want to request visible sections, false if you want to request all sections + * @returns {Observable} Observable that emits a list of MenuSections that are sub sections of the given menu and parent section + */ + getSubSectionsByParentID(menuID: MenuID, parentID: string, mustBeVisible = true): Observable { + return this.store.pipe( + select(menuByIDSelector(menuID)), + select(getSubSectionsFromSectionSelector(parentID)), + map((ids: string[]) => isNotEmpty(ids) ? ids : []), + switchMap((ids: string[]) => + observableCombineLatest(ids.map((id: string) => this.getMenuSection(menuID, id))) + ), + map((sections: MenuSection[]) => sections.filter((section: MenuSection) => !mustBeVisible || section.visible)) + ); + } + + /** + * Check if the a menu's top level section has subsections + * @param {MenuID} menuID The ID of the Menu + * @param {string} parentID The ID of the top level parent section + * @returns {Observable} Observable that emits true when the given parent section has sub sections, false if the given parent section does not have any sub sections + */ + hasSubSections(menuID: MenuID, parentID: string): Observable { + return this.store.pipe( + select(menuByIDSelector(menuID)), + select(getSubSectionsFromSectionSelector(parentID)), + map((ids: string[]) => isNotEmpty(ids)) + ); + } + + /** + * Retrieve a specific menu section by its menu ID and section ID + * @param {MenuID} menuID The ID of the menu the section resides in + * @param {string} sectionId The ID of the requested section + * @returns {Observable} Observable that emits the found MenuSection + */ + getMenuSection(menuID: MenuID, sectionId: string): Observable { + return this.store.pipe( + select(menuByIDSelector(menuID)), + select(menuSectionByIDSelector(sectionId)), + ); + } + + /** + * Add a new section to the store + * @param {MenuID} menuID The menu to which the new section is to be added + * @param {MenuSection} section The section to be added + */ + addSection(menuID: MenuID, section: MenuSection) { + this.store.dispatch(new AddMenuSectionAction(menuID, section)); + } + + /** + * Remove a section from the store + * @param {MenuID} menuID The menu from which the section is to be removed + * @param {string} sectionID The ID of the section that should be removed + */ + removeSection(menuID: MenuID, sectionID: string) { + this.store.dispatch(new RemoveMenuSectionAction(menuID, sectionID)); + } + + /** + * Check if a given menu is collapsed + * @param {MenuID} menuID The ID of the menu that is to be checked + * @returns {Observable} Emits true if the given menu is collapsed, emits falls when it's expanded + */ + isMenuCollapsed(menuID: MenuID): Observable { + return this.getMenu(menuID).pipe( + map((state: MenuState) => state.collapsed) + ); + } + + /** + * Check if a given menu's preview is collapsed + * @param {MenuID} menuID The ID of the menu that is to be checked + * @returns {Observable} Emits true if the given menu's preview is collapsed, emits falls when it's expanded + */ + isMenuPreviewCollapsed(menuID: MenuID): Observable { + return this.getMenu(menuID).pipe( + map((state: MenuState) => state.previewCollapsed) + ); + } + + /** + * Check if a given menu is visible + * @param {MenuID} menuID The ID of the menu that is to be checked + * @returns {Observable} Emits true if the given menu is visible, emits falls when it's hidden + */ + isMenuVisible(menuID: MenuID): Observable { + return this.getMenu(menuID).pipe( + map((state: MenuState) => state.visible) + ); + } + + /** + * Expands a given menu + * @param {MenuID} menuID The ID of the menu + */ + expandMenu(menuID: MenuID): void { + this.store.dispatch(new ExpandMenuAction(menuID)); + } + + /** + * Collapses a given menu + * @param {MenuID} menuID The ID of the menu + */ + collapseMenu(menuID: MenuID): void { + this.store.dispatch(new CollapseMenuAction(menuID)); + } + + /** + * Expands a given menu's preview + * @param {MenuID} menuID The ID of the menu + */ + expandMenuPreview(menuID: MenuID): void { + this.store.dispatch(new ExpandMenuPreviewAction(menuID)); + } + + /** + * Collapses a given menu's preview + * @param {MenuID} menuID The ID of the menu + */ + collapseMenuPreview(menuID: MenuID): void { + this.store.dispatch(new CollapseMenuPreviewAction(menuID)); + } + + /** + * Collapse a given menu when it's currently expanded or expand it when it's currently collapsed + * @param {MenuID} menuID The ID of the menu + */ + toggleMenu(menuID: MenuID): void { + this.store.dispatch(new ToggleMenuAction(menuID)); + } + + /** + * Show a given menu + * @param {MenuID} menuID The ID of the menu + */ + showMenu(menuID: MenuID): void { + this.store.dispatch(new ShowMenuAction(menuID)); + } + + /** + * Hide a given menu + * @param {MenuID} menuID The ID of the menu + */ + hideMenu(menuID: MenuID): void { + this.store.dispatch(new HideMenuAction(menuID)); + } + + /** + * Activate a given menu section when it's currently inactive or deactivate it when it's currently active + * @param {MenuID} menuID The ID of the menu + * @param {string} id The ID of the section + */ + toggleActiveSection(menuID: MenuID, id: string): void { + this.store.dispatch(new ToggleActiveMenuSectionAction(menuID, id)); + } + + /** + * Activate a given menu section + * @param {MenuID} menuID The ID of the menu + * @param {string} id The ID of the section + */ + activateSection(menuID: MenuID, id: string): void { + this.store.dispatch(new ActivateMenuSectionAction(menuID, id)); + } + + /** + * Deactivate a given menu section + * @param {MenuID} menuID The ID of the menu + * @param {string} id The ID of the section + */ + deactivateSection(menuID: MenuID, id: string): void { + this.store.dispatch(new DeactivateMenuSectionAction(menuID, id)); + } + + /** + * Check whether a given section is currently active or not + * @param {MenuID} menuID The ID of the Menu the section resides in + * @param {string} id The ID of the menu section to check + * @returns {Observable} Emits true when the given section is currently active, false when the given section is currently inactive + */ + isSectionActive(menuID: MenuID, id: string): Observable { + return this.getMenuSection(menuID, id).pipe(map((section) => section.active)); + } + + /** + * Check whether a given section is currently visible or not + * @param {MenuID} menuID The ID of the Menu the section resides in + * @param {string} id The ID of the menu section to check + * @returns {Observable} Emits true when the given section is currently visible, false when the given section is currently hidden + */ + isSectionVisible(menuID: MenuID, id: string): Observable { + return this.getMenuSection(menuID, id).pipe(map((section) => section.visible)); + } + +} diff --git a/src/app/shared/mocks/mock-form-builder-service.ts b/src/app/shared/mocks/mock-form-builder-service.ts index f37030eccb..e37df20e13 100644 --- a/src/app/shared/mocks/mock-form-builder-service.ts +++ b/src/app/shared/mocks/mock-form-builder-service.ts @@ -7,11 +7,17 @@ export function getMockFormBuilderService(): FormBuilderService { createFormGroup: new FormGroup({}), getValueFromModel: {}, getFormControlById: new FormControl(), + hasMappedGroupValue: false, findById: {}, getPath: ['test', 'path'], + getId: 'path', clearAllModelsValue : {}, insertFormArrayGroup: {}, - isQualdrop: false + isQualdrop: false, + isQualdropGroup: false, + isModelInCustomGroup: true, + isRelationGroup: true, + hasArrayGroupValue: true }); diff --git a/src/app/shared/mocks/mock-form-models.ts b/src/app/shared/mocks/mock-form-models.ts new file mode 100644 index 0000000000..5851da94be --- /dev/null +++ b/src/app/shared/mocks/mock-form-models.ts @@ -0,0 +1,258 @@ +import { DsDynamicInputModel } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { DynamicQualdropModel } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { + DynamicRowArrayModel, + DynamicRowArrayModelConfig +} from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { DynamicSelectModel } from '@ng-dynamic-forms/core'; +import { FormRowModel } from '../../core/config/models/config-submission-forms.model'; +import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; +import { DynamicRelationGroupModel } from '../form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { FormFieldModel } from '../form/builder/models/form-field.model'; +import { AuthorityOptions } from '../../core/integration/models/authority-options.model'; +import { AuthorityValue } from '../../core/integration/models/authority.value'; +import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; +import { DynamicRowGroupModel } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; + +export const qualdropSelectConfig = { + name: 'dc.identifier_QUALDROP_METADATA', + id: 'dc_identifier_QUALDROP_METADATA', + readOnly: false, + disabled: false, + label: 'Identifiers', + placeholder: 'Identifiers', + options: [ + { + label: 'ISSN', + value: 'dc.identifier.issn' + }, + { + label: 'Other', + value: 'dc.identifier.other' + }, + { + label: 'ISMN', + value: 'dc.identifier.ismn' + }, + { + label: 'Gov\'t Doc #', + value: 'dc.identifier.govdoc' + }, + { + label: 'URI', + value: 'dc.identifier.uri' + }, + { + label: 'ISBN', + value: 'dc.identifier.isbn' + } + ], + value: 'dc.identifier.issn' +}; + +export const qualdropInputConfig = { + name: 'dc.identifier_QUALDROP_VALUE', + id: 'dc_identifier_QUALDROP_VALUE', + readOnly: false, + disabled: false, + value: 'test' +}; + +export const mockQualdropSelectModel = new DynamicSelectModel(qualdropSelectConfig); +export const mockQualdropInputModel = new DsDynamicInputModel(qualdropInputConfig); + +export const qualdropConfig = { + id: 'dc_identifier_QUALDROP_GROUP', + legend: 'Identifiers', + readOnly: false, + group: [mockQualdropSelectModel, mockQualdropInputModel] +}; + +export const MockQualdropModel = new DynamicQualdropModel(qualdropConfig); + +const rowArrayQualdropConfig = { + id: 'row_QUALDROP_GROUP', + initialCount: 1, + notRepeatable: true, + groupFactory: () => { + return [MockQualdropModel]; + } +} as DynamicRowArrayModelConfig; + +export const MockRowArrayQualdropModel: DynamicRowArrayModel = new DynamicRowArrayModel(rowArrayQualdropConfig); + +const mockFormRowModel = { + fields: [ + { + input: {type: 'lookup'}, + label: 'Journal', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the journal where the item has been\n\t\t\t\t\tpublished, if any.', + selectableMetadata: [ + { + metadata: 'journal', + authority: 'JOURNALAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel, + { + input: {type: 'onebox'}, + label: 'Issue', + mandatory: 'false', + repeatable: false, + hints: ' Enter issue number.', + selectableMetadata: [ + { + metadata: 'issue' + } + ], + languageCodes: [] + } as FormFieldModel + ] +} as FormRowModel; + +const relationGroupConfig = { + id: 'relationGroup', + formConfiguration: [mockFormRowModel], + mandatoryField: 'false', + relationFields: ['journal', 'issue'], + scopeUUID: 'scope', + submissionScope: SubmissionScopeType.WorkspaceItem, + value: { + journal: [ + 'journal test 1', + 'journal test 2' + ], + issue: [ + 'issue test 1', + 'issue test 2' + ], + } +}; + +export const MockRelationModel: DynamicRelationGroupModel = new DynamicRelationGroupModel(relationGroupConfig); + +export const inputWithLanguageAndAuthorityConfig = { + authorityOptions: new AuthorityOptions('testAuthority', 'testWithAuthority', 'scope'), + languageCodes: [ + { + display: 'English', + code: 'en_US' + }, + { + display: 'Italian', + code: 'it_IT' + } + ], + language: 'en_US', + name: 'testWithAuthority', + id: 'testWithAuthority', + readOnly: false, + disabled: false, + value: { + value: 'testWithLanguageAndAuthority', + display: 'testWithLanguageAndAuthority', + id: 'testWithLanguageAndAuthority', + } +}; + +export const mockInputWithLanguageAndAuthorityModel = new DsDynamicInputModel(inputWithLanguageAndAuthorityConfig); + +export const inputWithLanguageConfig = { + languageCodes: [ + { + display: 'English', + code: 'en_US' + }, + { + display: 'Italian', + code: 'it_IT' + } + ], + language: 'en_US', + name: 'testWithLanguage', + id: 'testWithLanguage', + readOnly: false, + disabled: false, + value: 'testWithLanguage' +}; + +export const mockInputWithLanguageModel = new DsDynamicInputModel(inputWithLanguageConfig); + +export const inputWithLanguageAndAuthorityArrayConfig = { + authorityOptions: new AuthorityOptions('testAuthority', 'testWithAuthority', 'scope'), + languageCodes: [ + { + display: 'English', + code: 'en_US' + }, + { + display: 'Italian', + code: 'it_IT' + } + ], + language: 'en_US', + name: 'testWithLanguageAndAuthorityArray', + id: 'testWithLanguageAndAuthorityArray', + readOnly: false, + disabled: false, + value: [{ + value: 'testLanguageAndAuthorityArray', + display: 'testLanguageAndAuthorityArray', + id: 'testLanguageAndAuthorityArray', + }] +}; + +export const mockInputWithLanguageAndAuthorityArrayModel = new DsDynamicInputModel(inputWithLanguageAndAuthorityArrayConfig); + +export const inputWithFormFieldValueConfig = { + name: 'testWithFormField', + id: 'testWithFormField', + readOnly: false, + disabled: false, + value: new FormFieldMetadataValueObject('testWithFormFieldValue') +}; + +export const mockInputWithFormFieldValueModel = new DsDynamicInputModel(inputWithFormFieldValueConfig); + +export const inputWithAuthorityValueConfig = { + name: 'testWithAuthorityField', + id: 'testWithAuthorityField', + readOnly: false, + disabled: false, + value: Object.assign({}, new AuthorityValue(), { value: 'testWithAuthorityValue', id: 'testWithAuthorityValue', display: 'testWithAuthorityValue' }) +}; + +export const mockInputWithAuthorityValueModel = new DsDynamicInputModel(inputWithAuthorityValueConfig); + +export const inputWithObjectValueConfig = { + name: 'testWithObjectValue', + id: 'testWithObjectValue', + readOnly: false, + disabled: false, + value: { value: 'testWithObjectValue', id: 'testWithObjectValue', display: 'testWithObjectValue' } +}; + +export const mockInputWithObjectValueModel = new DsDynamicInputModel(inputWithObjectValueConfig); + +export const mockRowGroupModel = new DynamicRowGroupModel({ + id: 'mockRowGroupModel', + group: [mockInputWithFormFieldValueModel], +}); + +export const fileFormEditInputConfig = { + name: 'dc.title', + id: 'dc_title', + readOnly: false, + disabled: false, +}; + +export const mockFileFormEditInputModel = new DsDynamicInputModel(fileFormEditInputConfig); + +export const mockFileFormEditRowGroupModel = new DynamicRowGroupModel({ + id: 'mockRowGroupModel', + group: [mockFileFormEditInputModel] +}); diff --git a/src/app/shared/mocks/mock-form-operations-service.ts b/src/app/shared/mocks/mock-form-operations-service.ts new file mode 100644 index 0000000000..6fb6127087 --- /dev/null +++ b/src/app/shared/mocks/mock-form-operations-service.ts @@ -0,0 +1,20 @@ +import { SectionFormOperationsService } from '../../submission/sections/form/section-form-operations.service'; + +/** + * Mock for [[FormOperationsService]] + */ +export function getMockFormOperationsService(): SectionFormOperationsService { + return jasmine.createSpyObj('SectionFormOperationsService', { + dispatchOperationsFromEvent: jasmine.createSpy('dispatchOperationsFromEvent'), + getArrayIndexFromEvent: jasmine.createSpy('getArrayIndexFromEvent'), + isPartOfArrayOfGroup: jasmine.createSpy('isPartOfArrayOfGroup'), + getQualdropValueMap: jasmine.createSpy('getQualdropValueMap'), + getFieldPathFromEvent: jasmine.createSpy('getFieldPathFromEvent'), + getQualdropItemPathFromEvent: jasmine.createSpy('getQualdropItemPathFromEvent'), + getFieldPathSegmentedFromChangeEvent: jasmine.createSpy('getFieldPathSegmentedFromChangeEvent'), + getFieldValueFromChangeEvent: jasmine.createSpy('getFieldValueFromChangeEvent'), + getValueMap: jasmine.createSpy('getValueMap'), + + }); + +} diff --git a/src/app/shared/mocks/mock-form-service.ts b/src/app/shared/mocks/mock-form-service.ts index 0ab3fbffcc..d0510f3a68 100644 --- a/src/app/shared/mocks/mock-form-service.ts +++ b/src/app/shared/mocks/mock-form-service.ts @@ -1,12 +1,23 @@ +import { of as observableOf } from 'rxjs'; + import { FormService } from '../form/form.service'; +/** + * Mock for [[FormService]] + */ export function getMockFormService( id$: string = 'random_id' ): FormService { return jasmine.createSpyObj('FormService', { + getFormData: jasmine.createSpy('getFormData'), + initForm: jasmine.createSpy('initForm'), + removeForm: jasmine.createSpy('removeForm'), + getForm: observableOf({}), getUniqueId: id$, resetForm: {}, - validateAllFormFields: {} + validateAllFormFields: jasmine.createSpy('validateAllFormFields'), + isValid: jasmine.createSpy('isValid'), + isFormInitialized: observableOf(true) }); } diff --git a/src/app/shared/mocks/mock-item.ts b/src/app/shared/mocks/mock-item.ts index f3db69a0f2..98881436b9 100644 --- a/src/app/shared/mocks/mock-item.ts +++ b/src/app/shared/mocks/mock-item.ts @@ -51,14 +51,14 @@ export const MockItem: Item = Object.assign(new Item(), { id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', type: 'bitstream', - name: 'test_word.docx', - metadata: [ - { - key: 'dc.title', - language: null, - value: 'test_word.docx' - } - ] + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx' + } + ] + } }, { sizeBytes: 31302, @@ -86,14 +86,14 @@ export const MockItem: Item = Object.assign(new Item(), { id: '99b00f3c-1cc6-4689-8158-91965bee6b28', uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', type: 'bitstream', - name: 'test_pdf.pdf', - metadata: [ - { - key: 'dc.title', - language: null, - value: 'test_pdf.pdf' - } - ] + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_pdf.pdf' + } + ] + } } ] } @@ -102,99 +102,106 @@ export const MockItem: Item = Object.assign(new Item(), { id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', type: 'item', - name: 'Test PowerPoint Document', - metadata: [ - { - key: 'dc.creator', - language: 'en_US', - value: 'Doe, Jane' - }, - { - key: 'dc.date.accessioned', - language: null, - value: '1650-06-26T19:58:25Z' - }, - { - key: 'dc.date.available', - language: null, - value: '1650-06-26T19:58:25Z' - }, - { - key: 'dc.date.issued', - language: null, - value: '1650-06-26' - }, - { - key: 'dc.identifier.issn', - language: 'en_US', - value: '123456789' - }, - { - key: 'dc.identifier.uri', - language: null, - value: 'http://dspace7.4science.it/xmlui/handle/10673/6' - }, - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' - }, - { - key: 'dc.language', - language: 'en_US', - value: 'en' - }, - { - key: 'dc.rights', - language: 'en_US', - value: '© Jane Doe' - }, - { - key: 'dc.subject', - language: 'en_US', - value: 'keyword1' - }, - { - key: 'dc.subject', - language: 'en_US', - value: 'keyword2' - }, - { - key: 'dc.subject', - language: 'en_US', - value: 'keyword3' - }, - { - key: 'dc.title', - language: 'en_US', - value: 'Test PowerPoint Document' - }, - { - key: 'dc.type', - language: 'en_US', - value: 'text' - } - ], + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Test PowerPoint Document' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + }, owningCollection: observableOf({ self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', requestPending: false, diff --git a/src/app/shared/mocks/mock-remote-data-build.service.ts b/src/app/shared/mocks/mock-remote-data-build.service.ts index dea3b8d93f..675e539d90 100644 --- a/src/app/shared/mocks/mock-remote-data-build.service.ts +++ b/src/app/shared/mocks/mock-remote-data-build.service.ts @@ -1,16 +1,13 @@ - import {of as observableOf, Observable } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; -import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; import { RemoteData } from '../../core/data/remote-data'; import { RequestEntry } from '../../core/data/request.reducer'; import { hasValue } from '../empty.util'; -import { NormalizedObject } from '../../core/cache/models/normalized-object.model'; export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observable>): RemoteDataBuildService { return { - toRemoteDataObservable: (requestEntry$: Observable, responseCache$: Observable, payload$: Observable) => { + toRemoteDataObservable: (requestEntry$: Observable, payload$: Observable) => { if (hasValue(toRemoteDataObservable$)) { return toRemoteDataObservable$; diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts index d46100d56c..ce09f6d85e 100644 --- a/src/app/shared/mocks/mock-request.service.ts +++ b/src/app/shared/mocks/mock-request.service.ts @@ -2,10 +2,14 @@ import {of as observableOf, Observable } from 'rxjs'; import { RequestService } from '../../core/data/request.service'; import { RequestEntry } from '../../core/data/request.reducer'; -export function getMockRequestService(getByHref$: Observable = observableOf(new RequestEntry())): RequestService { +export function getMockRequestService(requestEntry$: Observable = observableOf(new RequestEntry())): RequestService { return jasmine.createSpyObj('requestService', { configure: false, generateRequestId: 'clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78', - getByHref: getByHref$ + getByHref: requestEntry$, + getByUUID: requestEntry$, + /* tslint:disable:no-empty */ + removeByHrefSubstring: () => {} + /* tslint:enable:no-empty */ }); } diff --git a/src/app/shared/mocks/mock-response-cache.service.ts b/src/app/shared/mocks/mock-response-cache.service.ts deleted file mode 100644 index a5a999873d..0000000000 --- a/src/app/shared/mocks/mock-response-cache.service.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {of as observableOf, Observable } from 'rxjs'; -import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; -import { ResponseCacheService } from '../../core/cache/response-cache.service'; - -export function getMockResponseCacheService( - add$: Observable = observableOf(new ResponseCacheEntry()), - get$: Observable = observableOf(new ResponseCacheEntry()), - has: boolean = false -): ResponseCacheService { - return jasmine.createSpyObj('ResponseCacheService', { - add: add$, - get: get$, - has, - }); - -} diff --git a/src/app/shared/mocks/mock-router.ts b/src/app/shared/mocks/mock-router.ts index 054c63d4c0..929e2644e8 100644 --- a/src/app/shared/mocks/mock-router.ts +++ b/src/app/shared/mocks/mock-router.ts @@ -1,4 +1,21 @@ +import { of as observableOf } from 'rxjs'; + +/** + * Mock for [[RouterService]] + */ export class MockRouter { + public events = observableOf({}); + public routerState = { + snapshot: { + url: '' + } + }; + // noinspection TypeScriptUnresolvedFunction navigate = jasmine.createSpy('navigate'); + navigateByUrl = jasmine.createSpy('navigateByUrl'); + + setRoute(route) { + this.routerState.snapshot.url = route; + } } diff --git a/src/app/shared/mocks/mock-scroll-to-service.ts b/src/app/shared/mocks/mock-scroll-to-service.ts new file mode 100644 index 0000000000..03b68e55d2 --- /dev/null +++ b/src/app/shared/mocks/mock-scroll-to-service.ts @@ -0,0 +1,10 @@ +import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; + +/** + * Mock for [[ScrollToService]] + */ +export function getMockScrollToService(): ScrollToService { + return jasmine.createSpyObj('scrollToService', { + scrollTo: jasmine.createSpy('scrollTo') + }); +} diff --git a/src/app/shared/mocks/mock-section-upload.service.ts b/src/app/shared/mocks/mock-section-upload.service.ts new file mode 100644 index 0000000000..9098fa64c0 --- /dev/null +++ b/src/app/shared/mocks/mock-section-upload.service.ts @@ -0,0 +1,15 @@ +import { SubmissionFormsConfigService } from '../../core/config/submission-forms-config.service'; + +/** + * Mock for [[SubmissionFormsConfigService]] + */ +export function getMockSectionUploadService(): SubmissionFormsConfigService { + return jasmine.createSpyObj('SectionUploadService', { + getUploadedFileList: jasmine.createSpy('getUploadedFileList'), + getFileData: jasmine.createSpy('getFileData'), + getDefaultPolicies: jasmine.createSpy('getDefaultPolicies'), + addUploadedFile: jasmine.createSpy('addUploadedFile'), + updateFileData: jasmine.createSpy('updateFileData'), + removeUploadedFile: jasmine.createSpy('removeUploadedFile') + }); +} diff --git a/src/app/shared/mocks/mock-store.ts b/src/app/shared/mocks/mock-store.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/mocks/mock-submission.ts b/src/app/shared/mocks/mock-submission.ts new file mode 100644 index 0000000000..922e6ad02d --- /dev/null +++ b/src/app/shared/mocks/mock-submission.ts @@ -0,0 +1,1596 @@ +import { SubmissionObjectState } from '../../submission/objects/submission-objects.reducer'; +import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; +import { Group } from '../../core/eperson/models/group.model'; + +export const mockSectionsData = { + traditionalpageone:{ + 'dc.title': [ + new FormFieldMetadataValueObject('test', null, null, 'test' ) + ]}, + license: { + url: null, + acceptanceDate: null, + granted: false + }, + upload: { + files: [] + } +}; + +export const mockSectionsDataTwo = { + traditionalpageone:{ + 'dc.title': [ + new FormFieldMetadataValueObject('test', null, null, 'test' ) + ]}, + traditionalpagetwo:{ + 'dc.relation': [ + new FormFieldMetadataValueObject('test', null, null, 'test' ) + ]}, + license: { + url: null, + acceptanceDate: null, + granted: false + }, + upload: { + files: [] + } +}; + +export const mockSectionsErrors = [ + { + message: 'error.validation.required', + paths: [ + '/sections/traditionalpageone/dc.contributor.author', + '/sections/traditionalpageone/dc.title', + '/sections/traditionalpageone/dc.date.issued' + ] + }, + { + message: 'error.validation.license.notgranted', + paths: [ + '/sections/license' + ] + } +]; + +export const mockUploadResponse1Errors = { + errors: [ + { + message: 'error.validation.required', + paths: [ + '/sections/traditionalpageone/dc.title', + '/sections/traditionalpageone/dc.date.issued' + ] + } + ] +}; + +export const mockUploadResponse1ParsedErrors: any = { + traditionalpageone: [ + { path: '/sections/traditionalpageone/dc.title', message: 'error.validation.required' }, + { path: '/sections/traditionalpageone/dc.date.issued', message: 'error.validation.required' } + ] +}; + +export const mockLicenseParsedErrors: any = { + license: [ + { path: '/sections/license', message: 'error.validation.license.notgranted' } + ] +}; + +export const mockUploadResponse2Errors = { + errors: [ + { + message: 'error.validation.required', + paths: [ + '/sections/traditionalpageone/dc.title', + '/sections/traditionalpageone/dc.date.issued' + ] + }, + { + message: 'error.upload', + paths: [ + '/sections/upload' + ] + } + ] +}; + +export const mockUploadResponse2ParsedErrors = { + traditionalpageone: [ + { path: '/sections/traditionalpageone/dc.title', message: 'error.validation.required' }, + { path: '/sections/traditionalpageone/dc.date.issued', message: 'error.validation.required' } + ], + upload: [ + { path: '/sections/upload', message: 'error.upload' } + ] +}; + +export const mockSubmissionRestResponse = [ + { + id: 826, + lastModified: '2018-08-03T12:49:45.268+0000', + collection: [ + { + handle: '10673/2', + license: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', + defaultAccessConditions: [], + logo: [ + { + sizeBytes: 7451, + content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', + format: [], + bundleName: null, + self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425', + id: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', + uuid: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', + type: 'bitstream', + name: null, + metadata: [], + _links: { + content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', + format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', + self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' + } + } + ], + self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + id: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + uuid: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + type: 'collection', + name: 'Collection of Sample Items', + metadata: [ + { + key: 'dc.provenance', + language: null, + value: 'This field is for private provenance information. It is only visible to Administrative users and is not displayed in the user interface by default.' + }, + { + key: 'dc.rights.license', + language: null, + value: '' + }, + { + key: 'dc.description', + language: null, + value: '

    This is a DSpace Collection which contains sample DSpace Items.

    \r\n

    Collections in DSpace may only contain Items.

    \r\n

    This particular Collection has its own logo (the Open Source Initiative logo).

    \r\n

    This introductory text is editable by System Administrators, Community Administrators (of a parent Community) or Collection Administrators (of this Collection).

    ' + }, + { + key: 'dc.description.abstract', + language: null, + value: 'This collection contains sample items.' + }, + { + key: 'dc.description.tableofcontents', + language: null, + value: '

    This is the news section for this Collection. System Administrators, Community Administrators (of a parent Community) or Collection Administrators (of this Collection) can edit this News field.

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

    If this collection had a specific copyright statement, it would be placed here.

    ' + }, + { + key: 'dc.title', + language: null, + value: 'Collection of Sample Items' + } + ], + _links: { + license: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', + defaultAccessConditions: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions', + logo: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo', + self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb' + } + } + ], + item: [ + { + handle: null, + lastModified: '2018-07-25T14:08:28.750+0000', + isArchived: false, + isDiscoverable: true, + isWithdrawn: false, + bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/bitstreams', + self: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5', + id: '6f344222-6980-4738-8192-b808d79af8a5', + uuid: '6f344222-6980-4738-8192-b808d79af8a5', + type: 'item', + name: null, + metadata: [], + _links: { + bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/bitstreams', + owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/owningCollection', + templateItemOf: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/templateItemOf', + self: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5' + } + } + ], + sections: {}, + submissionDefinition: [ + { + isDefault: true, + sections: [ + { + mandatory: true, + sectionType: 'utils', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + }, + { + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + { + header: 'submit.progressbar.describe.stepone', + mandatory: true, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' + }, + { + header: 'submit.progressbar.describe.steptwo', + mandatory: false, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' + }, + { + header: 'submit.progressbar.upload', + mandatory: true, + sectionType: 'upload', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' + }, + { + header: 'submit.progressbar.license', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + } + ], + name: 'traditional', + type: 'submissiondefinition', + _links: { + collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', + sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + } + ], + submitter: [], + errors: [], + self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', + type: 'workspaceitem', + _links: { + collection: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection', + item: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item', + submissionDefinition: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition', + submitter: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter', + self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' + } + } +]; + +export const mockSubmissionObject = { + collection: { + handle: '10673/2', + license: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', + defaultAccessConditions: { + pageInfo: { + elementsPerPage: 1, + totalElements: 1, + totalPages: 1, + currentPage: 1 + }, + page: [ + { + name: null, + groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', + id: 20, + uuid: 'resource-policy-20', + self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20', + type: 'resourcePolicy', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20' + } + } + ] + }, + logo: { + sizeBytes: 7451, + content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', + format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', + bundleName: null, + self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425', + id: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', + uuid: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', + type: 'bitstream', + name: null, + metadata: [], + _links: { + content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', + format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', + self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' + } + }, + self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + id: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + uuid: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + type: 'collection', + name: 'Collection of Sample Items', + metadata: [ + { + key: 'dc.provenance', + language: null, + value: 'This field is for private provenance information. It is only visible to Administrative users and is not displayed in the user interface by default.' + }, + { + key: 'dc.rights.license', + language: null, + value: '' + }, + { + key: 'dc.description', + language: null, + value: '

    This is a DSpace Collection which contains sample DSpace Items.

    \r\n

    Collections in DSpace may only contain Items.

    \r\n

    This particular Collection has its own logo (the Open Source Initiative logo).

    \r\n

    This introductory text is editable by System Administrators, Community Administrators (of a parent Community) or Collection Administrators (of this Collection).

    ' + }, + { + key: 'dc.description.abstract', + language: null, + value: 'This collection contains sample items.' + }, + { + key: 'dc.description.tableofcontents', + language: null, + value: '

    This is the news section for this Collection. System Administrators, Community Administrators (of a parent Community) or Collection Administrators (of this Collection) can edit this News field.

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

    If this collection had a specific copyright statement, it would be placed here.

    ' + }, + { + key: 'dc.title', + language: null, + value: 'Collection of Sample Items' + } + ], + _links: { + license: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', + defaultAccessConditions: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions', + logo: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo', + self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb' + } + }, + item: { + handle: null, + lastModified: '2019-01-09T10:17:33.722+0000', + isArchived: false, + isDiscoverable: true, + isWithdrawn: false, + owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection', + bitstreams: { + pageInfo: { + elementsPerPage: 0, + totalElements: 0, + totalPages: 1, + currentPage: 1 + }, + page: [] + }, + self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270', + id: 'cae8af78-c874-4468-af79-e6c996aa8270', + uuid: 'cae8af78-c874-4468-af79-e6c996aa8270', + type: 'item', + name: null, + metadata: [], + _links: { + bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/bitstreams', + owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection', + templateItemOf: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/templateItemOf', + self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270' + } + }, + submissionDefinition: { + isDefault: true, + sections: { + pageInfo: { + elementsPerPage: 5, + totalElements: 5, + totalPages: 1, + currentPage: 1 + }, + page: [ + { + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + { + header: 'submit.progressbar.describe.stepone', + mandatory: true, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' + }, + { + header: 'submit.progressbar.describe.steptwo', + mandatory: true, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' + }, + { + header: 'submit.progressbar.upload', + mandatory: true, + sectionType: 'upload', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' + }, + { + header: 'submit.progressbar.license', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + } + ] + }, + name: 'traditional', + type: 'submissiondefinition', + _links: { + collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', + sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional', + collections: { + pageInfo: { + elementsPerPage: 0, + totalElements: 0, + totalPages: 1, + currentPage: 1 + }, + page: [] + } + }, + submitter: { + handle: null, + groups: [], + netid: null, + lastActive: '2019-01-09T10:17:33.047+0000', + canLogIn: true, + email: 'dspacedemo+submit@gmail.com', + requireCertificate: false, + selfRegistered: false, + self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-5tg6-a9cd-6d910e68dca5', + id: '99423c27-b642-5tg6-a9cd-6d910e68dca5', + uuid: '99423c27-b642-5tg6-a9cd-6d910e68dca5', + type: 'eperson', + name: 'dspacedemo+submit@gmail.com', + metadata: [ + { + key: 'eperson.firstname', + language: null, + value: 'Demo' + }, + { + key: 'eperson.lastname', + language: null, + value: 'Submitter' + } + ], + _links: { + self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-5tg6-a9cd-6d910e68dca5' + } + }, + id: 826, + lastModified: '2019-01-09T10:17:33.738+0000', + sections: { + license: { + url: null, + acceptanceDate: null, + granted: false + }, + upload: { + files: [] + } + }, + errors: [ + { + message: 'error.validation.required', + paths: [ + '/sections/traditionalpageone/dc.title', + '/sections/traditionalpageone/dc.date.issued' + ] + } + ], + self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', + type: 'workspaceitem', + _links: { + collection: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection', + item: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item', + submissionDefinition: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition', + submitter: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter', + self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' + } +}; + +export const mockSubmissionObjectNew = { + collection: { + handle: '10673/2', + license: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', + defaultAccessConditions: { + pageInfo: { + elementsPerPage: 1, + totalElements: 1, + totalPages: 1, + currentPage: 1 + }, + page: [ + { + name: null, + groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', + id: 20, + uuid: 'resource-policy-20', + self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20', + type: 'resourcePolicy', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20' + } + } + ] + }, + logo: { + sizeBytes: 7451, + content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', + format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', + bundleName: null, + self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425', + id: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', + uuid: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', + type: 'bitstream', + name: null, + metadata: [], + _links: { + content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', + format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', + self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' + } + }, + self: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb', + id: '45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb', + uuid: '45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb', + type: 'collection', + name: 'Another Collection of Sample Items', + metadata: [ + { + key: 'dc.provenance', + language: null, + value: 'This field is for private provenance information. It is only visible to Administrative users and is not displayed in the user interface by default.' + }, + { + key: 'dc.rights.license', + language: null, + value: '' + }, + { + key: 'dc.description', + language: null, + value: '

    This is a DSpace Collection which contains sample DSpace Items.

    \r\n

    Collections in DSpace may only contain Items.

    \r\n

    This particular Collection has its own logo (the Open Source Initiative logo).

    \r\n

    This introductory text is editable by System Administrators, Community Administrators (of a parent Community) or Collection Administrators (of this Collection).

    ' + }, + { + key: 'dc.description.abstract', + language: null, + value: 'This collection contains sample items.' + }, + { + key: 'dc.description.tableofcontents', + language: null, + value: '

    This is the news section for this Collection. System Administrators, Community Administrators (of a parent Community) or Collection Administrators (of this Collection) can edit this News field.

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

    If this collection had a specific copyright statement, it would be placed here.

    ' + }, + { + key: 'dc.title', + language: null, + value: 'Collection of Sample Items' + } + ], + _links: { + license: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', + defaultAccessConditions: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions', + logo: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo', + self: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb' + } + }, + item: { + handle: null, + lastModified: '2019-01-09T10:17:33.722+0000', + isArchived: false, + isDiscoverable: true, + isWithdrawn: false, + owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection', + bitstreams: { + pageInfo: { + elementsPerPage: 0, + totalElements: 0, + totalPages: 1, + currentPage: 1 + }, + page: [] + }, + self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270', + id: 'cae8af78-c874-4468-af79-e6c996aa8270', + uuid: 'cae8af78-c874-4468-af79-e6c996aa8270', + type: 'item', + name: null, + metadata: [], + _links: { + bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/bitstreams', + owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection', + templateItemOf: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/templateItemOf', + self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270' + } + }, + submissionDefinition: { + isDefault: true, + sections: { + pageInfo: { + elementsPerPage: 5, + totalElements: 5, + totalPages: 1, + currentPage: 1 + }, + page: [ + { + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + { + header: 'submit.progressbar.describe.stepone', + mandatory: true, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' + }, + { + header: 'submit.progressbar.describe.steptwo', + mandatory: true, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' + }, + { + header: 'submit.progressbar.upload', + mandatory: true, + sectionType: 'upload', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' + }, + { + header: 'submit.progressbar.license', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + } + ] + }, + name: 'traditionaltwo', + type: 'submissiondefinition', + _links: { + collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', + sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional', + collections: { + pageInfo: { + elementsPerPage: 0, + totalElements: 0, + totalPages: 1, + currentPage: 1 + }, + page: [] + } + }, + submitter: { + handle: null, + groups: [], + netid: null, + lastActive: '2019-01-09T10:17:33.047+0000', + canLogIn: true, + email: 'dspacedemo+submit@gmail.com', + requireCertificate: false, + selfRegistered: false, + self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-4bb9-a9cd-45gh23e68dca5', + id: '99423c27-b642-4bb9-a9cd-45gh23e68dca5', + uuid: '99423c27-b642-4bb9-a9cd-45gh23e68dca5', + type: 'eperson', + name: 'dspacedemo+submit@gmail.com', + metadata: [ + { + key: 'eperson.firstname', + language: null, + value: 'Demo' + }, + { + key: 'eperson.lastname', + language: null, + value: 'Submitter' + } + ], + _links: { + self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-4bb9-a9cd-45gh23e68dca5' + } + }, + id: 826, + lastModified: '2019-01-09T10:17:33.738+0000', + sections: {}, + errors: [], + self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', + type: 'workspaceitem', + _links: { + collection: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection', + item: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item', + submissionDefinition: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition', + submitter: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter', + self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' + } +}; + +export const mockSubmissionCollectionId = '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb'; + +export const mockSubmissionId = '826'; + +export const mockSubmissionSelfUrl = 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826'; + +export const mockSubmissionDefinitionResponse = { + isDefault: true, + sections: [ + { + mandatory: true, + sectionType: 'utils', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + }, + { + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + { + header: 'submit.progressbar.describe.stepone', + mandatory: true, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' + }, + { + header: 'submit.progressbar.describe.steptwo', + mandatory: false, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' + }, + { + header: 'submit.progressbar.upload', + mandatory: true, + sectionType: 'upload', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' + }, + { + header: 'submit.progressbar.license', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + } + ], + name: 'traditional', + type: 'submissiondefinition', + _links: { + collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', + sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' +} as any; + +export const mockSubmissionDefinition: SubmissionDefinitionsModel = { + isDefault: true, + sections: new PaginatedList(new PageInfo(),[ + { + mandatory: true, + sectionType: 'utils', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + }, + { + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + }, + { + header: 'submit.progressbar.describe.stepone', + mandatory: true, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' + }, + { + header: 'submit.progressbar.describe.steptwo', + mandatory: false, + sectionType: 'submission-form', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' + }, + { + header: 'submit.progressbar.upload', + mandatory: true, + sectionType: 'upload', + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' + }, + { + header: 'submit.progressbar.license', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + } + ]), + name: 'traditional', + type: 'submissiondefinition', + _links: { + collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', + sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' +} as any; + +export const mockSubmissionState: SubmissionObjectState = Object.assign({}, { + 826: { + collection: mockSubmissionCollectionId, + definition: 'traditional', + selfUrl: mockSubmissionSelfUrl, + activeSection: null, + sections: { + extraction: { + config: '', + mandatory: true, + sectionType: 'utils', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + collection: { + config: '', + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + traditionalpageone: { + header: 'submit.progressbar.describe.stepone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + traditionalpagetwo: { + header: 'submit.progressbar.describe.steptwo', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + upload: { + header: 'submit.progressbar.upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload', + mandatory: true, + sectionType: 'upload', + collapsed: false, + enabled: true, + data: { + files: [] + }, + errors: [], + isLoading: false, + isValid: false + } as any, + license: { + header: 'submit.progressbar.license', + config: '', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any + }, + isLoading: false, + savePending: false, + depositPending: false + } +}); + +export const mockSectionsState = Object.assign({}, { + extraction: { + config: '', + mandatory: true, + sectionType: 'utils', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + collection: { + config: '', + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + traditionalpageone: { + header: 'submit.progressbar.describe.stepone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + traditionalpagetwo: { + header: 'submit.progressbar.describe.steptwo', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + upload: { + header: 'submit.progressbar.upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload', + mandatory: true, + sectionType: 'upload', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any, + license: { + header: 'submit.progressbar.license', + config: '', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any +}); + +export const mockSectionsList = [ + { + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.describe.stepone', + id: 'traditionalpageone', + sectionType: 'submission-form' + }, + { + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.describe.steptwo', + id: 'traditionalpagetwo', + sectionType: 'submission-form' + }, + { + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.upload', + id: 'upload', + sectionType: 'upload' + }, + { + config: '', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.license', + id: 'license', + sectionType: 'license' + } +]; + +export const mockUploadConfigResponse = { + accessConditionOptions: [ + { + name: 'openaccess', + groupUUID: '123456-g', + hasStartDate: false, + hasEndDate: false + }, + { + name: 'lease', + groupUUID: '123456-g', + hasStartDate: false, + hasEndDate: true, + maxEndDate: '2019-07-12T14:40:06.308+0000' + }, + { + name: 'embargo', + groupUUID: '123456-g', + hasStartDate: true, + hasEndDate: false, + maxStartDate: '2022-01-12T14:40:06.308+0000' + }, + { + name: 'administrator', + groupUUID: '0f2773dd-1741-475f-80e7-ccdef153d655', + hasStartDate: false, + hasEndDate: false + } + ], + metadata: { + rows: [ + { + fields: [ + { + input: { + type: 'onebox' + }, + label: 'Title', + mandatory: true, + repeatable: false, + mandatoryMessage: 'You must enter a main title for this item.', + hints: 'Enter the name of the file.', + selectableMetadata: [ + { + metadata: 'dc.title', + label: null, + authority: null, + closed: null + } + ], + languageCodes: [] + } + ] + }, + { + fields: [ + { + input: { + type: 'textarea' + }, + label: 'Description', + mandatory: false, + repeatable: true, + hints: 'Enter a description for the file', + selectableMetadata: [ + { + metadata: 'dc.description', + label: null, + authority: null, + closed: null + } + ], + languageCodes: [] + } + ] + } + ], + name: 'bitstream-metadata', + type: 'submissionform', + _links: { + self: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/bitstream-metadata' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/bitstream-metadata' + }, + required: false, + maxSize: 536870912, + name: 'upload', + type: 'submissionupload', + _links: { + metadata: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload/metadata', + self: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + }, + self: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' +}; + +export const mockAccessConditionOptions = [ + { + name: 'openaccess', + groupUUID: '123456-g', + hasStartDate: false, + hasEndDate: false + }, + { + name: 'lease', + groupUUID: '123456-g', + hasStartDate: false, + hasEndDate: true, + maxEndDate: '2019-07-12T14:40:06.308+0000' + }, + { + name: 'embargo', + groupUUID: '123456-g', + hasStartDate: true, + hasEndDate: false, + maxStartDate: '2022-01-12T14:40:06.308+0000' + }, + { + name: 'administrator', + groupUUID: '0f2773dd-1741-475f-80e7-ccdef153d655', + hasStartDate: false, + hasEndDate: false + } +]; + +export const mockGroup = Object.assign(new Group(), { + handle: null, + permanent: true, + self: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1', + id: '123456-g', + uuid: '123456-g', + type: 'group', + name: 'Anonymous', + metadata: [], + _links: { + groups: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1/groups', + self: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1' + }, + groups: { + pageInfo: { + elementsPerPage: 0, + totalElements: 0, + totalPages: 1, + currentPage: 1 + }, + page: [] + } +}); + +export const mockUploadFiles = [ + { + uuid: '123456-test-upload', + metadata: { + 'dc.source': [ + { + value: '123456-test-upload.jpg', + language: null, + authority: null, + display: '123456-test-upload.jpg', + confidence: -1, + place: 0, + otherInformation: null + } + ], + 'dc.title': [ + { + value: '123456-test-upload.jpg', + language: null, + authority: null, + display: '123456-test-upload.jpg', + confidence: -1, + place: 0, + otherInformation: null + } + ] + }, + accessConditions: [ + { + id: 3675, + name: 'lease', + rpType: 'TYPE_CUSTOM', + groupUUID: '123456-g', + action: 'READ', + endDate: '2019-01-16', + type: 'resourcePolicy' + }, + { + id: 3676, + name: 'openaccess', + rpType: 'TYPE_CUSTOM', + groupUUID: '123456-g', + action: 'READ', + type: 'resourcePolicy' + } + ], + format: { + id: 16, + shortDescription: 'JPEG', + description: 'Joint Photographic Experts Group/JPEG File Interchange Format (JFIF)', + mimetype: 'image/jpeg', + supportLevel: 0, + internal: false, + extensions: null, + type: 'bitstreamformat' + }, + sizeBytes: 202999, + checkSum: { + checkSumAlgorithm: 'MD5', + value: '5e0996996863d2623439cbb53052bc72' + }, + url: 'https://test-ui.com/api/core/bitstreams/123456-test-upload/content' + } +]; + +export const mockFileFormData = { + metadata: { + 'dc.title': [ + { + value: 'title', + language: null, + authority: null, + display: 'title', + confidence: -1, + place: 0, + otherInformation: null + } + ], + 'dc.description': [ + { + value: 'description', + language: null, + authority: null, + display: 'description', + confidence: -1, + place: 0, + otherInformation: null + } + ] + }, + accessConditions: [ + { + name: [ + { + value: 'openaccess', + language: null, + authority: null, + display: 'openaccess', + confidence: -1, + place: 0, + otherInformation: null + } + ], + groupUUID: [ + { + value: '123456-g', + language: null, + authority: null, + display: '123456-g', + confidence: -1, + place: 0, + otherInformation: null + } + ] + } + , + { + name: [ + { + value: 'lease', + language: null, + authority: null, + display: 'lease', + confidence: -1, + place: 0, + otherInformation: null + } + ], + endDate: [ + { + value: { + year: 2019, + month: 1, + day: 16 + }, + language: null, + authority: null, + display: { + year: 2019, + month: 1, + day: 16 + }, + confidence: -1, + place: 0, + otherInformation: null + } + ], + groupUUID: [ + { + value: '123456-g', + language: null, + authority: null, + display: '123456-g', + confidence: -1, + place: 0, + otherInformation: null + } + ] + } + , + { + name: [ + { + value: 'embargo', + language: null, + authority: null, + display: 'lease', + confidence: -1, + place: 0, + otherInformation: null + } + ], + startDate: [ + { + value: { + year: 2019, + month: 1, + day: 16 + }, + language: null, + authority: null, + display: { + year: 2019, + month: 1, + day: 16 + }, + confidence: -1, + place: 0, + otherInformation: null + } + ], + groupUUID: [ + { + value: '123456-g', + language: null, + authority: null, + display: '123456-g', + confidence: -1, + place: 0, + otherInformation: null + } + ] + } + ] +}; diff --git a/src/app/shared/mocks/mock-translate.service.ts b/src/app/shared/mocks/mock-translate.service.ts new file mode 100644 index 0000000000..8106b3788f --- /dev/null +++ b/src/app/shared/mocks/mock-translate.service.ts @@ -0,0 +1,8 @@ +import { TranslateService } from '@ngx-translate/core'; + +export function getMockTranslateService(): TranslateService { + return jasmine.createSpyObj('translateService', { + get: jasmine.createSpy('get'), + instant: jasmine.createSpy('instant') + }); +} diff --git a/src/app/shared/notifications/notification/notification.component.html b/src/app/shared/notifications/notification/notification.component.html index b35d6dee59..c5afdd5758 100644 --- a/src/app/shared/notifications/notification/notification.component.html +++ b/src/app/shared/notifications/notification/notification.component.html @@ -15,7 +15,7 @@
    { @@ -38,18 +42,28 @@ describe('NotificationComponent', () => { animate: 'scale' }as INotificationBoardOptions, } as any; - const service = new NotificationsService(envConfig, store); TestBed.configureTestingModule({ imports: [ BrowserModule, BrowserAnimationsModule, - StoreModule.forRoot({notificationsReducer})], + StoreModule.forRoot({notificationsReducer}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], declarations: [NotificationComponent], // declare the test component providers: [ - { provide: NotificationsService, useValue: service }, - ChangeDetectorRef] + { provide: GLOBAL_CONFIG, useValue: envConfig }, + { provide: Store, useValue: store }, + ChangeDetectorRef, + NotificationsService, + TranslateService, + ] }).compileComponents(); // compile template and css + })); beforeEach(() => { @@ -91,10 +105,10 @@ describe('NotificationComponent', () => { expect(elType).toBeDefined(); }); - it('shuld has html content', () => { + it('should have html content', () => { fixture = TestBed.createComponent(NotificationComponent); comp = fixture.componentInstance; - const htmlContent = `test` + const htmlContent = 'test'; comp.notification = { id: '1', type: NotificationType.Info, diff --git a/src/app/shared/notifications/notifications.reducers.ts b/src/app/shared/notifications/notifications.reducers.ts index 2dfd8f239a..c5afef416b 100644 --- a/src/app/shared/notifications/notifications.reducers.ts +++ b/src/app/shared/notifications/notifications.reducers.ts @@ -2,8 +2,7 @@ import { NotificationsActions, NotificationsActionTypes, RemoveNotificationActio import { INotification } from './models/notification.model'; /** - * The auth state. - * @interface State + * The notification state. */ export interface NotificationsState extends Array { @@ -38,6 +37,5 @@ export function notificationsReducer(state: any = initialState, action: Notifica } const removeNotification = (state: NotificationsState, action: RemoveNotificationAction): NotificationsState => { - const newState = state.filter((item: INotification) => item.id !== action.payload); - return newState; + return state.filter((item: INotification) => item.id !== action.payload); }; diff --git a/src/app/shared/notifications/notifications.service.spec.ts b/src/app/shared/notifications/notifications.service.spec.ts index a9f354dc4d..0f7cdbfd93 100644 --- a/src/app/shared/notifications/notifications.service.spec.ts +++ b/src/app/shared/notifications/notifications.service.spec.ts @@ -13,36 +13,50 @@ import { import { Notification } from './models/notification.model'; import { NotificationType } from './models/notification-type'; import { GlobalConfig } from '../../../config/global-config.interface'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../mocks/mock-translate-loader'; +import { GLOBAL_CONFIG } from '../../../config'; describe('NotificationsService test', () => { const store: Store = jasmine.createSpyObj('store', { dispatch: {}, select: observableOf(true) }); - let service; + let service: NotificationsService; let envConfig: GlobalConfig; + envConfig = { + notifications: { + rtl: false, + position: ['top', 'right'], + maxStack: 8, + timeOut: 5000, + clickToClose: true, + animate: 'scale' + }, + } as any; + beforeEach(async () => { TestBed.configureTestingModule({ - declarations: [NotificationComponent, NotificationsBoardComponent], - providers: [NotificationsService], imports: [ StoreModule.forRoot({notificationsReducer}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [NotificationComponent, NotificationsBoardComponent], + providers: [ + { provide: GLOBAL_CONFIG, useValue: envConfig }, + { provide: Store, useValue: store }, + NotificationsService, + TranslateService ] }); - envConfig = { - notifications: { - rtl: false, - position: ['top', 'right'], - maxStack: 8, - timeOut: 5000, - clickToClose: true, - animate: 'scale' - }, - } as any; - - service = new NotificationsService(envConfig, store); + service = TestBed.get(NotificationsService); }); it('Success method should dispatch NewNotificationAction with proper parameter', () => { diff --git a/src/app/shared/notifications/notifications.service.ts b/src/app/shared/notifications/notifications.service.ts index 55df6a4f7f..4a8d5fb912 100644 --- a/src/app/shared/notifications/notifications.service.ts +++ b/src/app/shared/notifications/notifications.service.ts @@ -1,22 +1,24 @@ -import { of as observableOf, Observable } from 'rxjs'; import { Inject, Injectable } from '@angular/core'; + +import { of as observableOf } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { uniqueId } from 'lodash'; + import { INotification, Notification } from './models/notification.model'; import { NotificationType } from './models/notification-type'; import { NotificationOptions } from './models/notification-options.model'; -import { uniqueId } from 'lodash'; -import { Store } from '@ngrx/store'; -import { - NewNotificationAction, - RemoveAllNotificationsAction, - RemoveNotificationAction -} from './notifications.actions'; + +import { NewNotificationAction, RemoveAllNotificationsAction, RemoveNotificationAction } from './notifications.actions'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; @Injectable() export class NotificationsService { constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig, - private store: Store) { + private store: Store, + private translate: TranslateService) { } private add(notification: Notification) { @@ -27,27 +29,30 @@ export class NotificationsService { success(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Success, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Success, title, content, notificationOptions, html); this.add(notification); return notification; } error(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Error, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Error, title, content, notificationOptions, html); this.add(notification); return notification; } info(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Info, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Info, title, content, notificationOptions, html); this.add(notification); return notification; } @@ -56,11 +61,47 @@ export class NotificationsService { content: any = observableOf(''), options: NotificationOptions = this.getDefaultOptions(), html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, notificationOptions, html); this.add(notification); return notification; } + notificationWithAnchor(notificationType: NotificationType, + options: NotificationOptions, + href: string, + hrefTranslateLabel: string, + messageTranslateLabel: string, + interpolateParam: string) { + this.translate.get(hrefTranslateLabel) + .pipe(first()) + .subscribe((hrefMsg) => { + const anchor = ` + ${hrefMsg} + `; + const interpolateParams = Object.create({}); + interpolateParams[interpolateParam] = anchor; + this.translate.get(messageTranslateLabel, interpolateParams) + .pipe(first()) + .subscribe((m) => { + switch (notificationType) { + case NotificationType.Success: + this.success(null, m, options, true); + break; + case NotificationType.Error: + this.error(null, m, options, true); + break; + case NotificationType.Info: + this.info(null, m, options, true); + break; + case NotificationType.Warning: + this.warning(null, m, options, true); + break; + } + }); + }); + } + remove(notification: INotification) { const actionRemove = new RemoveNotificationAction(notification.id); this.store.dispatch(actionRemove); diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index b1d07db876..a81ee5a882 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -2,6 +2,11 @@ [sortConfig]="sortConfig" [objects]="objects" [hideGear]="hideGear" + (paginationChange)="onPaginationChange($event)" + (pageChange)="onPageChange($event)" + (pageSizeChange)="onPageSizeChange($event)" + (sortDirectionChange)="onSortDirectionChange($event)" + (sortFieldChange)="onSortFieldChange($event)" *ngIf="getViewMode()===viewModeEnum.List"> @@ -9,6 +14,11 @@ [sortConfig]="sortConfig" [objects]="objects" [hideGear]="hideGear" + (paginationChange)="onPaginationChange($event)" + (pageChange)="onPageChange($event)" + (pageSizeChange)="onPageSizeChange($event)" + (sortDirectionChange)="onSortDirectionChange($event)" + (sortFieldChange)="onSortFieldChange($event)" *ngIf="getViewMode()===viewModeEnum.Grid"> diff --git a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts index 59df86fdff..d52036b5dc 100644 --- a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts +++ b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts @@ -1,6 +1,5 @@ import { Component, Inject } from '@angular/core'; import { ListableObject } from '../listable-object.model'; -import { hasValue } from '../../../empty.util'; @Component({ selector: 'ds-abstract-object-element', @@ -11,8 +10,4 @@ export class AbstractListableElementComponent { public constructor(@Inject('objectElementProvider') public listableObject: ListableObject) { this.object = listableObject as T; } - - hasValue(data) { - return hasValue(data); - } } diff --git a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts index cc72ff3043..66807c6b20 100644 --- a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts @@ -8,21 +8,25 @@ let collectionGridElementComponent: CollectionGridElementComponent; let fixture: ComponentFixture; const mockCollectionWithAbstract: 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 mockCollectionWithoutAbstract: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionGridElementComponent', () => { diff --git a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts index dabb137ea7..bb6c81144a 100644 --- a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts @@ -8,21 +8,25 @@ let communityGridElementComponent: CommunityGridElementComponent; let fixture: ComponentFixture; const mockCommunityWithAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunityGridElementComponent', () => { diff --git a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html index 728dba7549..6bb2cfa99d 100644 --- a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html +++ b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html @@ -5,19 +5,19 @@
    -

    {{object.findMetadata('dc.title')}}

    +

    {{object.firstMetadataValue('dc.title')}}

    -

    - {{authorMd.value}} +

    + {{author}} ; - {{object.findMetadata("dc.date.issued")}} + {{object.firstMetadataValue("dc.date.issued")}}

    -

    {{object.findMetadata("dc.description.abstract") }}

    +

    {{object.firstMetadataValue("dc.description.abstract")}}

    diff --git a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts index f2aa594296..7b286cc415 100644 --- a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts @@ -11,31 +11,37 @@ let fixture: ComponentFixture; const mockItemWithAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts index 8e6ff0696f..e8f8b1330e 100644 --- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCollectionWithAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithAbstract.hitHighlights = []; +mockCollectionWithAbstract.hitHighlights = {}; mockCollectionWithAbstract.dspaceObject = 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 mockCollectionWithoutAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithoutAbstract.hitHighlights = []; +mockCollectionWithoutAbstract.hitHighlights = {}; mockCollectionWithoutAbstract.dspaceObject = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionSearchResultGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts index df8fdf026d..e111e624c5 100644 --- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCommunityWithAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithAbstract.hitHighlights = []; +mockCommunityWithAbstract.hitHighlights = {}; mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - } ] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithoutAbstract.hitHighlights = []; +mockCommunityWithoutAbstract.hitHighlights = {}; mockCommunityWithoutAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunitySearchResultGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html index 1e4f6f3c64..c7e2f524f3 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html @@ -8,20 +8,20 @@
    -

    +

    -

    - {{dso.findMetadata("dc.date.issued")}} - , - + {{dso.firstMetadataValue('dc.date.issued')}} + , +

    - +

    diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts index ecc218f11d..0103fa5c49 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts @@ -17,37 +17,43 @@ const truncatableServiceStub: any = { }; const mockItemWithAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithAuthorAndDate.hitHighlights = []; +mockItemWithAuthorAndDate.hitHighlights = {}; mockItemWithAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithoutAuthorAndDate.hitHighlights = []; +mockItemWithoutAuthorAndDate.hitHighlights = {}; mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemSearchResultGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts index 5fd1c87edd..844d0bc165 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts @@ -2,12 +2,11 @@ import { Component, Inject } from '@angular/core'; import { SearchResult } from '../../../+search-page/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Metadatum } from '../../../core/shared/metadatum.model'; -import { isEmpty, hasNoValue, hasValue } from '../../empty.util'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { ListableObject } from '../../object-collection/shared/listable-object.model'; import { TruncatableService } from '../../truncatable/truncatable.service'; import { Observable } from 'rxjs'; +import { Metadata } from '../../../core/shared/metadata.utils'; @Component({ selector: 'ds-search-result-grid-element', @@ -22,39 +21,24 @@ export class SearchResultGridElementComponent, K exten this.dso = this.object.dspaceObject; } - getValues(keys: string[]): string[] { - const results: string[] = new Array(); - this.object.hitHighlights.forEach( - (md: Metadatum) => { - if (keys.indexOf(md.key) > -1) { - results.push(md.value); - } - } - ); - if (isEmpty(results)) { - this.dso.filterMetadata(keys).forEach( - (md: Metadatum) => { - results.push(md.value); - } - ); - } - return results; + /** + * Gets all matching metadata string values from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string[]} the matching string values or an empty array. + */ + allMetadataValues(keyOrKeys: string | string[]): string[] { + return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } - getFirstValue(key: string): string { - let result: string; - this.object.hitHighlights.some( - (md: Metadatum) => { - if (key === md.key) { - result = md.value; - return true; - } - } - ); - if (hasNoValue(result)) { - result = this.dso.findMetadata(key); - } - return result; + /** + * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string} the first matching string value, or `undefined`. + */ + firstMetadataValue(keyOrKeys: string | string[]): string { + return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } isCollapsed(): Observable { diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html index 198e79b453..0fad726777 100644 --- a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html @@ -1,5 +1,5 @@
    - + {{object.value}}   diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts index de53f2e095..54b58e131a 100644 --- a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts @@ -2,7 +2,6 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; import { TruncatePipe } from '../../utils/truncate.pipe'; -import { Metadatum } from '../../../core/shared/metadatum.model'; import { BrowseEntryListElementComponent } from './browse-entry-list-element.component'; import { BrowseEntry } from '../../../core/shared/browse-entry.model'; @@ -33,7 +32,7 @@ describe('MetadataListElementComponent', () => { browseEntryListElementComponent = fixture.componentInstance; })); - describe('When the metadatum is loaded', () => { + describe('When the metadata is loaded', () => { beforeEach(() => { browseEntryListElementComponent.object = mockValue; fixture.detectChanges(); diff --git a/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts b/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts index a31af1e50c..bde6b4b97a 100644 --- a/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts +++ b/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts @@ -8,21 +8,25 @@ let collectionListElementComponent: CollectionListElementComponent; let fixture: ComponentFixture; const mockCollectionWithAbstract: 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 mockCollectionWithoutAbstract: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionListElementComponent', () => { diff --git a/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts b/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts index 08147d8573..46ba27eb00 100644 --- a/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts +++ b/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts @@ -8,21 +8,25 @@ let communityListElementComponent: CommunityListElementComponent; let fixture: ComponentFixture; const mockCommunityWithAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunityListElementComponent', () => { diff --git a/src/app/shared/object-list/item-list-element/item-list-element.component.html b/src/app/shared/object-list/item-list-element/item-list-element.component.html index 711ce19037..8179b77629 100644 --- a/src/app/shared/object-list/item-list-element/item-list-element.component.html +++ b/src/app/shared/object-list/item-list-element/item-list-element.component.html @@ -1,23 +1,23 @@ - {{object.findMetadata("dc.title")}} + {{object.firstMetadataValue("dc.title")}}
    - - {{authorMd.value}} + {{author}} ; - ({{object.findMetadata("dc.publisher")}}, {{object.findMetadata("dc.date.issued")}}) + ({{object.firstMetadataValue("dc.publisher")}}, {{object.firstMetadataValue("dc.date.issued")}}) -
    - {{object.findMetadata("dc.description.abstract")}} +
    + {{object.firstMetadataValue("dc.description.abstract")}}
    diff --git a/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts index 64108fd5b0..392d81bee4 100644 --- a/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts +++ b/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts @@ -11,31 +11,37 @@ let fixture: ComponentFixture; const mockItemWithAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html index be549b2b76..b4af631e83 100644 --- a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html @@ -1,2 +1,2 @@ - -
    + +
    diff --git a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts index 2ffaf38b53..e897071a00 100644 --- a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCollectionWithAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithAbstract.hitHighlights = []; +mockCollectionWithAbstract.hitHighlights = {}; mockCollectionWithAbstract.dspaceObject = 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 mockCollectionWithoutAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithoutAbstract.hitHighlights = []; +mockCollectionWithoutAbstract.hitHighlights = {}; mockCollectionWithoutAbstract.dspaceObject = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionSearchResultListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html index 150ca503cc..9444a63771 100644 --- a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html @@ -1,2 +1,2 @@ - -
    + +
    diff --git a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts index 70877d0744..75d5966767 100644 --- a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCommunityWithAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithAbstract.hitHighlights = []; +mockCommunityWithAbstract.hitHighlights = {}; mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - } ] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithoutAbstract.hitHighlights = []; +mockCommunityWithoutAbstract.hitHighlights = {}; mockCommunityWithoutAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunitySearchResultListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html index 584d476e73..6261220459 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html @@ -1,24 +1,24 @@ + [innerHTML]="firstMetadataValue('dc.title')"> - () - ) + - + -
    +
    + [innerHTML]="firstMetadataValue('dc.description.abstract')">
    - \ No newline at end of file + diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts index bdc8ebcecf..8567fc1782 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts @@ -17,37 +17,43 @@ const truncatableServiceStub: any = { }; const mockItemWithAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithAuthorAndDate.hitHighlights = []; +mockItemWithAuthorAndDate.hitHighlights = {}; mockItemWithAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithoutAuthorAndDate.hitHighlights = []; +mockItemWithoutAuthorAndDate.hitHighlights = {}; mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemSearchResultListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index 6a3b698dd6..525d39e798 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -3,11 +3,10 @@ import { Observable } from 'rxjs'; import { SearchResult } from '../../../+search-page/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Metadatum } from '../../../core/shared/metadatum.model'; -import { hasNoValue, isEmpty } from '../../empty.util'; import { ListableObject } from '../../object-collection/shared/listable-object.model'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { TruncatableService } from '../../truncatable/truncatable.service'; +import { Metadata } from '../../../core/shared/metadata.utils'; @Component({ selector: 'ds-search-result-list-element', @@ -22,39 +21,24 @@ export class SearchResultListElementComponent, K exten this.dso = this.object.dspaceObject; } - getValues(keys: string[]): string[] { - const results: string[] = new Array(); - this.object.hitHighlights.forEach( - (md: Metadatum) => { - if (keys.indexOf(md.key) > -1) { - results.push(md.value); - } - } - ); - if (isEmpty(results)) { - this.dso.filterMetadata(keys).forEach( - (md: Metadatum) => { - results.push(md.value); - } - ); - } - return results; + /** + * Gets all matching metadata string values from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string[]} the matching string values or an empty array. + */ + allMetadataValues(keyOrKeys: string | string[]): string[] { + return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } - getFirstValue(key: string): string { - let result: string; - this.object.hitHighlights.some( - (md: Metadatum) => { - if (key === md.key) { - result = md.value; - return true; - } - } - ); - if (hasNoValue(result)) { - result = this.dso.findMetadata(key); - } - return result; + /** + * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string} the first matching string value, or `undefined`. + */ + firstMetadataValue(keyOrKeys: string | string[]): string { + return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } isCollapsed(): Observable { diff --git a/src/app/shared/pagination/pagination.component.html b/src/app/shared/pagination/pagination.component.html index e974bb6eb0..7336866e5c 100644 --- a/src/app/shared/pagination/pagination.component.html +++ b/src/app/shared/pagination/pagination.component.html @@ -7,12 +7,12 @@
    - +
    - + - +
    diff --git a/src/app/shared/sass-helper/sass-helper.actions.ts b/src/app/shared/sass-helper/sass-helper.actions.ts new file mode 100644 index 0000000000..82890c074e --- /dev/null +++ b/src/app/shared/sass-helper/sass-helper.actions.ts @@ -0,0 +1,29 @@ +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 CSSVariableActionTypes = { + ADD: type('dspace/css-variables/ADD'), +}; + +export class AddCSSVariableAction implements Action { + type = CSSVariableActionTypes.ADD; + payload: { + name: string, + value: string + }; + + constructor(name: string, value: string) { + this.payload = {name, value}; + } +} +/* tslint:enable:max-classes-per-file */ + +export type CSSVariableAction = AddCSSVariableAction diff --git a/src/app/shared/sass-helper/sass-helper.reducer.ts b/src/app/shared/sass-helper/sass-helper.reducer.ts new file mode 100644 index 0000000000..6f080619fa --- /dev/null +++ b/src/app/shared/sass-helper/sass-helper.reducer.ts @@ -0,0 +1,20 @@ +import { CSSVariableAction, CSSVariableActionTypes } from './sass-helper.actions'; + +export interface CSSVariablesState { + [name: string]: string; +} + +const initialState: CSSVariablesState = Object.create({}); + +export function cssVariablesReducer(state = initialState, action: CSSVariableAction): CSSVariablesState { + switch (action.type) { + case CSSVariableActionTypes.ADD: { + const variable = action.payload; + const t = Object.assign({}, state, { [variable.name]: variable.value }); + return t; + } + default: { + return state; + } + } +} diff --git a/src/app/shared/sass-helper/sass-helper.service.ts b/src/app/shared/sass-helper/sass-helper.service.ts new file mode 100644 index 0000000000..7d32004daa --- /dev/null +++ b/src/app/shared/sass-helper/sass-helper.service.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@angular/core'; +import { AppState, keySelector } from '../../app.reducer'; +import { MemoizedSelector, select, Store } from '@ngrx/store'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { AddCSSVariableAction } from './sass-helper.actions'; + +@Injectable() +export class CSSVariableService { + constructor( + protected store: Store, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) { + } + + addCSSVariable(name: string, value: string) { + this.store.dispatch(new AddCSSVariableAction(name, value)); + } + + getVariable(name: string) { + return this.store.pipe(select(themeVariableByNameSelector(name))); + } + + getAllVariables() { + return this.store.pipe(select(themeVariablesSelector)); + } + +} + +const themeVariablesSelector = (state: AppState) => state.cssVariables; + +const themeVariableByNameSelector = (name: string): MemoizedSelector => { + return keySelector(name, themeVariablesSelector); +}; diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 30f5801cc2..b164abee1f 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -30,6 +30,7 @@ describe('SearchFormComponent', () => { }); it('should display scopes when available with default and all scopes', () => { + comp.scopes = objects; fixture.detectChanges(); const select: HTMLElement = de.query(By.css('select')).nativeElement; @@ -121,34 +122,38 @@ export const objects: DSpaceObject[] = [ 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(), { @@ -171,34 +176,38 @@ export const objects: DSpaceObject[] = [ 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/shared/services/route.service.spec.ts b/src/app/shared/services/route.service.spec.ts index 65109a2d28..7d249cb12a 100644 --- a/src/app/shared/services/route.service.spec.ts +++ b/src/app/shared/services/route.service.spec.ts @@ -1,10 +1,19 @@ -import { RouteService } from './route.service'; +import { ActivatedRoute, convertToParamMap, NavigationEnd, Params, Router } from '@angular/router'; import { async, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap, Params } from '@angular/router'; + import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { getTestScheduler, hot } from 'jasmine-marbles'; + +import { RouteService } from './route.service'; +import { MockRouter } from '../mocks/mock-router'; +import { TestScheduler } from 'rxjs/testing'; +import { AddUrlToHistoryAction } from '../history/history.actions'; describe('RouteService', () => { + let scheduler: TestScheduler; let service: RouteService; + let serviceAsAny: any; const paramName1 = 'name'; const paramValue1 = 'Test Name'; const paramName2 = 'id'; @@ -15,6 +24,11 @@ describe('RouteService', () => { const paramObject: Params = {}; + const store: any = jasmine.createSpyObj('store', { + dispatch: jasmine.createSpy('dispatch'), + select: jasmine.createSpy('select') + }); + paramObject[paramName1] = paramValue1; paramObject[paramName2] = [paramValue2a, paramValue2b]; @@ -28,12 +42,15 @@ describe('RouteService', () => { queryParamMap: observableOf(convertToParamMap(paramObject)) }, }, + { provide: Router, useValue: new MockRouter() }, + { provide: Store, useValue: store }, ] }); })); beforeEach(() => { - service = new RouteService(TestBed.get(ActivatedRoute)); + service = new RouteService(TestBed.get(ActivatedRoute), TestBed.get(Router), TestBed.get(Store)); + serviceAsAny = service; }); describe('hasQueryParam', () => { @@ -101,4 +118,31 @@ describe('RouteService', () => { }); }); + describe('saveRouting', () => { + + it('should dispatch AddUrlToHistoryAction on NavigationEnd event', () => { + scheduler = getTestScheduler(); + + serviceAsAny.router.events = hot('a-b', { + a: new NavigationEnd(0, 'url', 'url'), + b: new NavigationEnd(1, 'newurl', 'newurl') + }); + + scheduler.schedule(() => service.saveRouting()); + scheduler.flush(); + + expect(serviceAsAny.store.dispatch).toHaveBeenCalledWith(new AddUrlToHistoryAction('url')); + expect(serviceAsAny.store.dispatch).toHaveBeenCalledWith(new AddUrlToHistoryAction('newurl')); + }); + }); + + describe('getHistory', () => { + it('should dispatch AddUrlToHistoryAction on NavigationEnd event', () => { + serviceAsAny.store = observableOf({ history: ['url', 'newurl'] }); + + service.getHistory().subscribe((history) => { + expect(history).toEqual(['url', 'newurl']); + }) + }) + }) }); diff --git a/src/app/shared/services/route.service.ts b/src/app/shared/services/route.service.ts index d72367c977..48fc3ba0b0 100644 --- a/src/app/shared/services/route.service.ts +++ b/src/app/shared/services/route.service.ts @@ -1,25 +1,38 @@ -import { distinctUntilChanged, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { - ActivatedRoute, convertToParamMap, NavigationExtras, Params, - Router, -} from '@angular/router'; -import { isNotEmpty } from '../empty.util'; +import { ActivatedRoute, NavigationEnd, Params, Router, } from '@angular/router'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { select, Store } from '@ngrx/store'; + +import { AppState } from '../../app.reducer'; +import { AddUrlToHistoryAction } from '../history/history.actions'; +import { historySelector } from '../history/selectors'; + +/** + * Service to keep track of the current query parameters + */ @Injectable() export class RouteService { - constructor(private route: ActivatedRoute) { + constructor(private route: ActivatedRoute, private router: Router, private store: Store) { } + /** + * Retrieves all query parameter values based on a parameter name + * @param paramName The name of the parameter to look for + */ getQueryParameterValues(paramName: string): Observable { return this.route.queryParamMap.pipe( map((params) => [...params.getAll(paramName)]), - distinctUntilChanged() + distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)) ); } + /** + * Retrieves a single query parameter values based on a parameter name + * @param paramName The name of the parameter to look for + */ getQueryParameterValue(paramName: string): Observable { return this.route.queryParamMap.pipe( map((params) => params.get(paramName)), @@ -27,6 +40,10 @@ export class RouteService { ); } + /** + * Checks if the query parameter currently exists in the route + * @param paramName The name of the parameter to look for + */ hasQueryParam(paramName: string): Observable { return this.route.queryParamMap.pipe( map((params) => params.has(paramName)), @@ -34,6 +51,11 @@ export class RouteService { ); } + /** + * Checks if the query parameter with a specific value currently exists in the route + * @param paramName The name of the parameter to look for + * @param paramValue The value of the parameter to look for + */ hasQueryParamWithValue(paramName: string, paramValue: string): Observable { return this.route.queryParamMap.pipe( map((params) => params.getAll(paramName).indexOf(paramValue) > -1), @@ -41,6 +63,10 @@ export class RouteService { ); } + /** + * Retrieves all query parameters of which the parameter name starts with the given prefix + * @param prefix The prefix of the parameter name to look for + */ getQueryParamsWithPrefix(prefix: string): Observable { return this.route.queryParamMap.pipe( map((qparams) => { @@ -52,6 +78,26 @@ export class RouteService { }); return params; }), - distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),); + distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), + ); } + + public saveRouting(): void { + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe(({ urlAfterRedirects }: NavigationEnd) => { + this.store.dispatch(new AddUrlToHistoryAction(urlAfterRedirects)) + }); + } + + public getHistory(): Observable { + return this.store.pipe(select(historySelector)); + } + + public getPreviousUrl(): Observable { + return this.getHistory().pipe( + map((history: string[]) => history[history.length - 2] || '') + ); + } + } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 305039e70b..4b0f783437 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -4,12 +4,7 @@ import { RouterModule } from '@angular/router'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NouisliderModule } from 'ng2-nouislider'; -import { - NgbDatepickerModule, - NgbModule, - NgbTimepickerModule, - NgbTypeaheadModule -} from '@ng-bootstrap/ng-bootstrap'; +import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; @@ -30,7 +25,6 @@ import { ItemListElementComponent } from './object-list/item-list-element/item-l import { SearchResultListElementComponent } from './object-list/search-result-list-element/search-result-list-element.component'; import { WrapperListElementComponent } from './object-list/wrapper-list-element/wrapper-list-element.component'; import { ObjectListComponent } from './object-list/object-list.component'; - import { CollectionGridElementComponent } from './object-grid/collection-grid-element/collection-grid-element.component'; import { CommunityGridElementComponent } from './object-grid/community-grid-element/community-grid-element.component'; import { ItemGridElementComponent } from './object-grid/item-grid-element/item-grid-element.component'; @@ -56,9 +50,12 @@ import { LogOutComponent } from './log-out/log-out.component'; import { FormComponent } from './form/form.component'; import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; -import { DsDynamicFormControlComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component'; +import { + DsDynamicFormControlContainerComponent, + dsDynamicFormControlMapFn +} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; -import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; +import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TextMaskModule } from 'angular2-text-mask'; import { DragClickDirective } from './utils/drag-click.directive'; @@ -70,13 +67,17 @@ import { UploaderComponent } from './uploader/uploader.component'; import { ChipsComponent } from './chips/chips.component'; import { DsDynamicTagComponent } from './form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component'; import { DsDynamicListComponent } from './form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component'; -import { DsDynamicGroupComponent } from './form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components'; +import { DsDynamicFormGroupComponent } from './form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component'; +import { DsDynamicFormArrayComponent } from './form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component'; +import { DsDynamicRelationGroupComponent } from './form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components'; +import { DsDatePickerInlineComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component'; import { SortablejsModule } from 'angular-sortablejs'; import { NumberPickerComponent } from './number-picker/number-picker.component'; import { DsDatePickerComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component'; import { DsDynamicLookupComponent } from './form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component'; import { MockAdminGuard } from './mocks/mock-admin-guard.service'; -import { BrowseByModule } from '../+browse-by/browse-by.module'; +import { AlertComponent } from './alert/alert.component'; +import { ObjNgFor } from './utils/object-ngfor.pipe'; import { BrowseByComponent } from './browse-by/browse-by.component'; import { BrowseEntryListElementComponent } from './object-list/browse-entry-list-element/browse-entry-list-element.component'; import { DebounceDirective } from './utils/debounce.directive'; @@ -86,6 +87,30 @@ import { InputSuggestionsComponent } from './input-suggestions/input-suggestions import { CapitalizePipe } from './utils/capitalize.pipe'; import { ObjectKeysPipe } from './utils/object-keys-pipe'; import { MomentModule } from 'ngx-moment'; +import { AuthorityConfidenceStateDirective } from './authority-confidence/authority-confidence-state.directive'; +import { MenuModule } from './menu/menu.module'; +import { ComColFormComponent } from './comcol-forms/comcol-form/comcol-form.component'; +import { CreateComColPageComponent } from './comcol-forms/create-comcol-page/create-comcol-page.component'; +import { EditComColPageComponent } from './comcol-forms/edit-comcol-page/edit-comcol-page.component'; +import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component'; +import { LangSwitchComponent } from './lang-switch/lang-switch.component'; +import { ObjectValuesPipe } from './utils/object-values-pipe'; +import { InListValidator } from './utils/in-list-validator.directive'; +import { AutoFocusDirective } from './utils/auto-focus.directive'; +import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component'; +import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component'; +import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component'; +import { DSOSelectorComponent } from './dso-selector/dso-selector/dso-selector.component'; +import { CreateCommunityParentSelectorComponent } from './dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { CreateItemParentSelectorComponent } from './dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { CreateCollectionParentSelectorComponent } from './dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { CommunitySearchResultListElementComponent } from './object-list/search-result-list-element/community-search-result/community-search-result-list-element.component'; +import { CollectionSearchResultListElementComponent } from './object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component'; +import { ItemSearchResultListElementComponent } from './object-list/search-result-list-element/item-search-result/item-search-result-list-element.component'; +import { EditItemSelectorComponent } from './dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { EditCommunitySelectorComponent } from './dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { EditCollectionSelectorComponent } from './dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; +import { DSOSelectorModalWrapperComponent } from './dso-selector/modal-wrappers/dso-selector-modal-wrapper.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -106,7 +131,8 @@ const MODULES = [ TranslateModule, NouisliderModule, MomentModule, - TextMaskModule + TextMaskModule, + MenuModule ]; const PIPES = [ @@ -118,27 +144,39 @@ const PIPES = [ EmphasizePipe, CapitalizePipe, ObjectKeysPipe, - ConsolePipe + ObjectValuesPipe, + ConsolePipe, + ObjNgFor ]; const COMPONENTS = [ // put shared components here + AlertComponent, AuthNavMenuComponent, ChipsComponent, ComcolPageContentComponent, ComcolPageHeaderComponent, ComcolPageLogoComponent, + ComColFormComponent, + CreateComColPageComponent, + EditComColPageComponent, + DeleteComColPageComponent, + ComcolPageBrowseByComponent, DsDynamicFormComponent, - DsDynamicFormControlComponent, + DsDynamicFormControlContainerComponent, DsDynamicListComponent, DsDynamicLookupComponent, DsDynamicScrollableDropdownComponent, DsDynamicTagComponent, DsDynamicTypeaheadComponent, - DsDynamicGroupComponent, + DsDynamicRelationGroupComponent, DsDatePickerComponent, + DsDynamicFormGroupComponent, + DsDynamicFormArrayComponent, + DsDatePickerInlineComponent, ErrorComponent, FormComponent, + LangSwitchComponent, LoadingComponent, LogInComponent, LogOutComponent, @@ -159,7 +197,17 @@ const COMPONENTS = [ TruncatableComponent, TruncatablePartComponent, BrowseByComponent, - InputSuggestionsComponent + InputSuggestionsComponent, + DSOSelectorComponent, + CreateCommunityParentSelectorComponent, + CreateCollectionParentSelectorComponent, + CreateItemParentSelectorComponent, + EditCommunitySelectorComponent, + EditCollectionSelectorComponent, + EditItemSelectorComponent, + CommunitySearchResultListElementComponent, + CollectionSearchResultListElementComponent, + ItemSearchResultListElementComponent, ]; const ENTRY_COMPONENTS = [ @@ -168,23 +216,52 @@ const ENTRY_COMPONENTS = [ CollectionListElementComponent, CommunityListElementComponent, SearchResultListElementComponent, + CommunitySearchResultListElementComponent, + CollectionSearchResultListElementComponent, + ItemSearchResultListElementComponent, ItemGridElementComponent, CollectionGridElementComponent, CommunityGridElementComponent, SearchResultGridElementComponent, - BrowseEntryListElementComponent + BrowseEntryListElementComponent, + DsDynamicListComponent, + DsDynamicLookupComponent, + DsDynamicScrollableDropdownComponent, + DsDynamicTagComponent, + DsDynamicTypeaheadComponent, + DsDynamicRelationGroupComponent, + DsDatePickerComponent, + DsDynamicFormGroupComponent, + DsDynamicFormArrayComponent, + DsDatePickerInlineComponent, + StartsWithDateComponent, + StartsWithTextComponent, + DSOSelectorComponent, + CreateCommunityParentSelectorComponent, + CreateCollectionParentSelectorComponent, + CreateItemParentSelectorComponent, + EditCommunitySelectorComponent, + EditCollectionSelectorComponent, + EditItemSelectorComponent, ]; const PROVIDERS = [ TruncatableService, - MockAdminGuard + MockAdminGuard, + { + provide: DYNAMIC_FORM_CONTROL_MAP_FN, + useValue: dsDynamicFormControlMapFn + } ]; const DIRECTIVES = [ VarDirective, DragClickDirective, DebounceDirective, - ClickOutsideDirective + ClickOutsideDirective, + AuthorityConfidenceStateDirective, + InListValidator, + AutoFocusDirective ]; @NgModule({ @@ -196,7 +273,6 @@ const DIRECTIVES = [ ...COMPONENTS, ...DIRECTIVES, ...ENTRY_COMPONENTS, - ...DIRECTIVES ], providers: [ ...PROVIDERS diff --git a/src/app/shared/starts-with/date/starts-with-date.component.html b/src/app/shared/starts-with/date/starts-with-date.component.html new file mode 100644 index 0000000000..3f024c3254 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.html @@ -0,0 +1,34 @@ +
    +
    + + {{'browse.startsWith.jump' | translate}} + +
    + +
    +
    + +
    +
    + + + + +
    +
    +
    diff --git a/src/app/shared/starts-with/date/starts-with-date.component.scss b/src/app/shared/starts-with/date/starts-with-date.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/date/starts-with-date.component.spec.ts b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts new file mode 100644 index 0000000000..10a168ab05 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts @@ -0,0 +1,183 @@ +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 { ActivatedRoute, Router } from '@angular/router'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { By } from '@angular/platform-browser'; +import { StartsWithDateComponent } from './starts-with-date.component'; +import { ActivatedRouteStub } from '../../testing/active-router-stub'; +import { EnumKeysPipe } from '../../utils/enum-keys-pipe'; +import { RouterStub } from '../../testing/router-stub'; + +describe('StartsWithDateComponent', () => { + let comp: StartsWithDateComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let router: Router; + + const options = [2019, 2018, 2017, 2016, 2015]; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [StartsWithDateComponent, EnumKeysPipe], + providers: [ + { provide: 'startsWithOptions', useValue: options }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Router, useValue: new RouterStub() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StartsWithDateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + router = (comp as any).router; + }); + + it('should create a FormGroup containing a startsWith FormControl', () => { + expect(comp.formData.value.startsWith).toBeDefined(); + }); + + describe('when selecting the first option in the year dropdown', () => { + let select; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select#year-select')).nativeElement; + select.value = select.options[0].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to undefined', () => { + expect(comp.startsWith).toBeUndefined(); + }); + + it('should not add a startsWith query parameter', () => { + route.queryParams.subscribe((params) => { + expect(params.startsWith).toBeUndefined(); + }); + }); + }); + + describe('when selecting the second option in the year dropdown', () => { + let select; + let input; + let expectedValue; + let extras; + + beforeEach(() => { + expectedValue = '' + options[0]; + extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + select = fixture.debugElement.query(By.css('select#year-select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + + describe('and selecting the first option in the month dropdown', () => { + let monthSelect; + + beforeEach(() => { + monthSelect = fixture.debugElement.query(By.css('select#month-select')).nativeElement; + monthSelect.value = monthSelect.options[0].value; + monthSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('and selecting the second option in the month dropdown', () => { + let monthSelect; + + beforeEach(() => { + expectedValue = `${options[0]}-01`; + extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + monthSelect = fixture.debugElement.query(By.css('select#month-select')).nativeElement; + monthSelect.value = monthSelect.options[1].value; + monthSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + }); + + describe('when filling in the input form', () => { + let form; + const expectedValue = '2015'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('form')); + comp.formData.value.startsWith = expectedValue; + form.triggerEventHandler('ngSubmit', null); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + }); + +}); diff --git a/src/app/shared/starts-with/date/starts-with-date.component.ts b/src/app/shared/starts-with/date/starts-with-date.component.ts new file mode 100644 index 0000000000..5f87ce8635 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.ts @@ -0,0 +1,140 @@ +import { Component } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Date". + * The options are rendered in a dropdown with an input field (of type number) next to it. + */ +@Component({ + selector: 'ds-starts-with-date', + styleUrls: ['./starts-with-date.component.scss'], + templateUrl: './starts-with-date.component.html' +}) +@renderStartsWithFor(StartsWithType.date) +export class StartsWithDateComponent extends StartsWithAbstractComponent { + + /** + * A list of options for months to select from + */ + monthOptions: string[]; + + /** + * Currently selected month + */ + startsWithMonth = 'none'; + + /** + * Currently selected year + */ + startsWithYear: number; + + ngOnInit() { + this.monthOptions = [ + 'none', + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december' + ]; + + super.ngOnInit(); + } + + /** + * Set the startsWith by event + * @param event + */ + setStartsWithYearEvent(event: Event) { + this.startsWithYear = +(event.target as HTMLInputElement).value; + this.setStartsWithYearMonth(); + this.setStartsWithParam(); + } + + /** + * Set the startsWithMonth by event + * @param event + */ + setStartsWithMonthEvent(event: Event) { + this.startsWithMonth = (event.target as HTMLInputElement).value; + this.setStartsWithYearMonth(); + this.setStartsWithParam(); + } + + /** + * Get startsWith year combined with month; + * Returned value: "{{year}}-{{month}}" + */ + getStartsWith() { + const month = this.getStartsWithMonth(); + if (month > 0 && hasValue(this.startsWithYear) && this.startsWithYear !== -1) { + let twoDigitMonth = '' + month; + if (month < 10) { + twoDigitMonth = `0${month}`; + } + return `${this.startsWithYear}-${twoDigitMonth}`; + } else { + if (hasValue(this.startsWithYear) && this.startsWithYear > 0) { + return '' + this.startsWithYear; + } else { + return undefined; + } + } + } + + /** + * Set startsWith year combined with month; + */ + setStartsWithYearMonth() { + this.startsWith = this.getStartsWith(); + } + + /** + * Set the startsWith by string + * This method also sets startsWithYear and startsWithMonth correctly depending on the received value + * - When startsWith contains a "-", the first part is considered the year, the second part the month + * - When startsWith doesn't contain a "-", the whole string is expected to be the year + * startsWithMonth will be set depending on the index received after the "-" + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + if (hasValue(startsWith) && startsWith.indexOf('-') > -1) { + const split = startsWith.split('-'); + this.startsWithYear = +split[0]; + const month = +split[1]; + if (month < this.monthOptions.length) { + this.startsWithMonth = this.monthOptions[month]; + } else { + this.startsWithMonth = this.monthOptions[0]; + } + } else { + this.startsWithYear = +startsWith; + } + this.setStartsWithParam(); + } + + /** + * Get startsWithYear as a number; + */ + getStartsWithYear() { + return this.startsWithYear; + } + + /** + * Get startsWithMonth as a number; + */ + getStartsWithMonth() { + return this.monthOptions.indexOf(this.startsWithMonth); + } + +} diff --git a/src/app/shared/starts-with/starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts new file mode 100644 index 0000000000..967ec7a844 --- /dev/null +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -0,0 +1,94 @@ +import { Inject, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { FormControl, FormGroup } from '@angular/forms'; +import { hasValue } from '../empty.util'; + +/** + * An abstract component to render StartsWith options + */ +export class StartsWithAbstractComponent implements OnInit, OnDestroy { + /** + * The currently selected startsWith in string format + */ + startsWith: string; + + /** + * The formdata controlling the StartsWith input + */ + formData: FormGroup; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + public constructor(@Inject('startsWithOptions') public startsWithOptions: any[], + protected route: ActivatedRoute, + protected router: Router) { + } + + ngOnInit(): void { + this.subs.push( + this.route.queryParams.subscribe((params) => { + if (hasValue(params.startsWith)) { + this.setStartsWith(params.startsWith); + } + }) + ); + this.formData = new FormGroup({ + startsWith: new FormControl() + }); + } + + /** + * Get startsWith + */ + getStartsWith(): any { + return this.startsWith; + } + + /** + * Set the startsWith by event + * @param event + */ + setStartsWithEvent(event: Event) { + this.startsWith = (event.target as HTMLInputElement).value; + this.setStartsWithParam(); + } + + /** + * Set the startsWith by string + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + this.setStartsWithParam(); + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '-1') { + this.startsWith = undefined; + } + this.router.navigate([], { + queryParams: Object.assign({ startsWith: this.startsWith }), + queryParamsHandling: 'merge' + }); + } + + /** + * Submit the form data. Called when clicking a submit button on the form. + * @param data + */ + submitForm(data) { + this.startsWith = data.startsWith; + this.setStartsWithParam(); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/shared/starts-with/starts-with-decorator.spec.ts b/src/app/shared/starts-with/starts-with-decorator.spec.ts new file mode 100644 index 0000000000..0ba72d8ac4 --- /dev/null +++ b/src/app/shared/starts-with/starts-with-decorator.spec.ts @@ -0,0 +1,13 @@ +import { renderStartsWithFor, StartsWithType } from './starts-with-decorator'; + +describe('BrowseByStartsWithDecorator', () => { + const textDecorator = renderStartsWithFor(StartsWithType.text); + const dateDecorator = renderStartsWithFor(StartsWithType.date); + it('should have a decorator for both text and date', () => { + expect(textDecorator.length).not.toBeNull(); + expect(dateDecorator.length).not.toBeNull(); + }); + it('should have 2 separate decorators for text and date', () => { + expect(textDecorator).not.toEqual(dateDecorator); + }); +}); diff --git a/src/app/shared/starts-with/starts-with-decorator.ts b/src/app/shared/starts-with/starts-with-decorator.ts new file mode 100644 index 0000000000..7592f00a8b --- /dev/null +++ b/src/app/shared/starts-with/starts-with-decorator.ts @@ -0,0 +1,30 @@ +const startsWithMap = new Map(); + +/** + * An enum that defines the type of StartsWith options + */ +export enum StartsWithType { + text = 'Text', + date = 'Date' +} + +/** + * Fetch a decorator to render a StartsWith component for type + * @param type + */ +export function renderStartsWithFor(type: StartsWithType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + startsWithMap.set(type, objectElement); + }; +} + +/** + * Get the correct component depending on the StartsWith type + * @param type + */ +export function getStartsWithComponent(type: StartsWithType) { + return startsWithMap.get(type); +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.html b/src/app/shared/starts-with/text/starts-with-text.component.html new file mode 100644 index 0000000000..dd7f4de278 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.html @@ -0,0 +1,29 @@ +
    +
    +
    + +
    +
    + +
    +
    + + + + +
    +
    +
    diff --git a/src/app/shared/starts-with/text/starts-with-text.component.scss b/src/app/shared/starts-with/text/starts-with-text.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.spec.ts b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts new file mode 100644 index 0000000000..653c7e6196 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts @@ -0,0 +1,178 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +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 '../../utils/enum-keys-pipe'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { StartsWithTextComponent } from './starts-with-text.component'; + +describe('StartsWithTextComponent', () => { + let comp: StartsWithTextComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let router: Router; + + const options = ['0-9', 'A', 'B', 'C']; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [StartsWithTextComponent, EnumKeysPipe], + providers: [ + { provide: 'startsWithOptions', useValue: options } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StartsWithTextComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + router = (comp as any).router; + spyOn(router, 'navigate'); + }); + + it('should create a FormGroup containing a startsWith FormControl', () => { + expect(comp.formData.value.startsWith).toBeDefined(); + }); + + describe('when selecting the first option in the dropdown', () => { + let select; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + select.value = select.options[0].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to undefined', () => { + expect(comp.startsWith).toBeUndefined(); + }); + + it('should not add a startsWith query parameter', () => { + route.queryParams.subscribe((params) => { + expect(params.startsWith).toBeUndefined(); + }); + }); + }); + + describe('when selecting "0-9" in the dropdown', () => { + let select; + let input; + const expectedValue = '0'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to "0"', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when selecting an option in the dropdown', () => { + let select; + let input; + const expectedValue = options[1]; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[2].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the expected value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when clicking an option in the list', () => { + let optionLink; + let input; + const expectedValue = options[1]; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + optionLink = fixture.debugElement.query(By.css('.list-inline-item:nth-child(2) > a')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + optionLink.click(); + fixture.detectChanges(); + }); + + it('should set startsWith to the expected value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when filling in the input form', () => { + let form; + const expectedValue = 'A'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('form')); + comp.formData.value.startsWith = expectedValue; + form.triggerEventHandler('ngSubmit', null); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + }); + +}); diff --git a/src/app/shared/starts-with/text/starts-with-text.component.ts b/src/app/shared/starts-with/text/starts-with-text.component.ts new file mode 100644 index 0000000000..ad89ce5c70 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.ts @@ -0,0 +1,49 @@ +import { Component, Inject } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Text". + */ +@Component({ + selector: 'ds-starts-with-text', + styleUrls: ['./starts-with-text.component.scss'], + templateUrl: './starts-with-text.component.html' +}) +@renderStartsWithFor(StartsWithType.text) +export class StartsWithTextComponent extends StartsWithAbstractComponent { + + /** + * Get startsWith as text; + */ + getStartsWith() { + if (hasValue(this.startsWith)) { + return this.startsWith; + } else { + return ''; + } + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '0-9') { + this.startsWith = '0'; + } + super.setStartsWithParam(); + } + + /** + * Checks whether the provided option is equal to the current startsWith + * @param option + */ + isSelectedOption(option: string): boolean { + if (this.startsWith === '0' && option === '0-9') { + return true; + } + return option === this.startsWith; + } + +} diff --git a/src/app/shared/testing/active-router-stub.ts b/src/app/shared/testing/active-router-stub.ts index 89a417149a..36a3f7de58 100644 --- a/src/app/shared/testing/active-router-stub.ts +++ b/src/app/shared/testing/active-router-stub.ts @@ -12,6 +12,7 @@ export class ActivatedRouteStub { params = this.subject.asObservable(); queryParams = this.subject.asObservable(); + paramMap = this.subject.asObservable().pipe(map((params: Params) => convertToParamMap(params)));; queryParamMap = this.subject.asObservable().pipe(map((params: Params) => convertToParamMap(params))); constructor(params?: Params) { diff --git a/src/app/shared/testing/auth-service-stub.ts b/src/app/shared/testing/auth-service-stub.ts index 415d15696b..fa263da31f 100644 --- a/src/app/shared/testing/auth-service-stub.ts +++ b/src/app/shared/testing/auth-service-stub.ts @@ -93,4 +93,8 @@ export class AuthServiceStub { public storeToken(token: AuthTokenInfo) { return; } + + isAuthenticated() { + return observableOf(true); + } } diff --git a/src/app/shared/testing/authority-service-stub.ts b/src/app/shared/testing/authority-service-stub.ts index 1122901f7f..3a5d31ab0d 100644 --- a/src/app/shared/testing/authority-service-stub.ts +++ b/src/app/shared/testing/authority-service-stub.ts @@ -2,13 +2,13 @@ import {of as observableOf, Observable } from 'rxjs'; import { IntegrationSearchOptions } from '../../core/integration/models/integration-options.model'; import { IntegrationData } from '../../core/integration/integration-data'; import { PageInfo } from '../../core/shared/page-info.model'; -import { AuthorityValueModel } from '../../core/integration/models/authority-value.model'; +import { AuthorityValue } from '../../core/integration/models/authority.value'; export class AuthorityServiceStub { private _payload = [ - Object.assign(new AuthorityValueModel(),{id: 1, display: 'one', value: 1}), - Object.assign(new AuthorityValueModel(),{id: 2, display: 'two', value: 2}), + Object.assign(new AuthorityValue(),{id: 1, display: 'one', value: 1}), + Object.assign(new AuthorityValue(),{id: 2, display: 'two', value: 2}), ]; setNewPayload(payload) { diff --git a/src/app/shared/testing/css-variable-service-stub.ts b/src/app/shared/testing/css-variable-service-stub.ts new file mode 100644 index 0000000000..0dbae758cd --- /dev/null +++ b/src/app/shared/testing/css-variable-service-stub.ts @@ -0,0 +1,22 @@ +import { Observable } from 'rxjs/internal/Observable'; +import { of as observableOf } from 'rxjs'; +const variables = { + smMin: '576px,', + mdMin: '768px,', + lgMin: '992px', + xlMin: '1200px', +} as any; + +export class CSSVariableServiceStub { + getVariable(name: string): Observable { + return observableOf('500px'); + } + + getAllVariables(name: string): Observable { + return observableOf(variables); + } + + addCSSVariable(name: string, value: string): void { + /**/ + } +} diff --git a/src/app/shared/testing/eperson-mock.ts b/src/app/shared/testing/eperson-mock.ts index f163a490b9..ef27f4983d 100644 --- a/src/app/shared/testing/eperson-mock.ts +++ b/src/app/shared/testing/eperson-mock.ts @@ -13,8 +13,12 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(),{ id: 'testid', uuid: 'testid', type: 'eperson', - name: 'User Test', metadata: [ + { + key: 'dc.title', + language: null, + value: 'User Test' + }, { key: 'eperson.firstname', language: null, diff --git a/src/app/shared/testing/menu-service-stub.ts b/src/app/shared/testing/menu-service-stub.ts new file mode 100644 index 0000000000..de71e3483d --- /dev/null +++ b/src/app/shared/testing/menu-service-stub.ts @@ -0,0 +1,93 @@ +import { MenuID } from '../menu/initial-menus-state'; +import { of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { MenuSection } from '../menu/menu.reducer'; + +export class MenuServiceStub { + visibleSection1 = { + id: 'section', + visible: true, + active: false + } as any; + visibleSection2 = { + id: 'section_2', + visible: true + } as any; + hiddenSection3 = { + id: 'section_3', + visible: false + } as any; + subSection4 = { + id: 'section_4', + visible: true, + parentID: 'section1' + } as any; + + toggleMenu(): void { /***/ + }; + + expandMenu(): void { /***/ + }; + + collapseMenu(): void { /***/ + }; + + showMenu(): void { /***/ + }; + + hideMenu(): void { /***/ + }; + + expandMenuPreview(): void { /***/ + }; + + collapseMenuPreview(): void { /***/ + }; + + toggleActiveSection(): void { /***/ + }; + + activateSection(): void { /***/ + }; + + deactivateSection(): void { /***/ + }; + + addSection(): void { /***/ + }; + + removeSection(): void { /***/ + }; + + isMenuVisible(id: MenuID): Observable { + return observableOf(true) + }; + + isMenuCollapsed(id: MenuID): Observable { + return observableOf(false) + }; + + isMenuPreviewCollapsed(id: MenuID): Observable { + return observableOf(true) + }; + + hasSubSections(id: MenuID, sectionID: string): Observable { + return observableOf(true) + }; + + getMenuTopSections(id: MenuID): Observable { + return observableOf([this.visibleSection1, this.visibleSection2]) + }; + + getSubSectionsByParentID(id: MenuID): Observable { + return observableOf([this.subSection4]) + }; + + isSectionActive(id: MenuID, sectionID: string): Observable { + return observableOf(true) + }; + + isSectionVisible(id: MenuID, sectionID: string): Observable { + return observableOf(true) + }; +} diff --git a/src/app/shared/testing/mock-store.ts b/src/app/shared/testing/mock-store.ts index 5223852c59..a6093f6bcb 100644 --- a/src/app/shared/testing/mock-store.ts +++ b/src/app/shared/testing/mock-store.ts @@ -1,24 +1,21 @@ -import { map } from 'rxjs/operators'; -import { Action } from '@ngrx/store'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { ActionsSubject, ReducerManager, StateObservable, Store } from '@ngrx/store'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; -export class MockStore extends BehaviorSubject { +@Injectable() +export class MockStore extends Store { + private stateSubject = new BehaviorSubject({} as T); - constructor(private _initialState: T) { - super(_initialState); + constructor( + state$: StateObservable, + actionsObserver: ActionsSubject, + reducerManager: ReducerManager + ) { + super(state$, actionsObserver, reducerManager); + this.source = this.stateSubject.asObservable(); } - dispatch = (action: Action): void => { - // console.info(action); - }; - - select = (pathOrMapFn: any): Observable => { - return this.asObservable().pipe( - map((value) => pathOrMapFn.projector(value))) - }; - - nextState(_newState: T) { - this.next(_newState); + nextState(nextState: T) { + this.stateSubject.next(nextState); } - } diff --git a/src/app/shared/testing/mock-submission-config.ts b/src/app/shared/testing/mock-submission-config.ts new file mode 100644 index 0000000000..3be82f65ee --- /dev/null +++ b/src/app/shared/testing/mock-submission-config.ts @@ -0,0 +1,54 @@ +import { SubmissionConfig } from '../../../config/submission-config.interface'; +import { GlobalConfig } from '../../../config/global-config.interface'; + +export const MOCK_SUBMISSION_CONFIG = { + 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: [ + { + name: 'mainField', + style: 'fas fa-user' + }, + { + name: 'relatedField', + style: 'fas fa-university' + }, + { + name: 'otherRelatedField', + style: 'fas fa-circle' + }, + { + name: 'default', + style: '' + } + ], + authority: { + confidence: [ + { + value: 600, + style: 'text-success' + }, + { + value: 500, + style: 'text-info' + }, + { + value: 400, + style: 'text-warning' + }, + { + value: 'default', + style: 'text-muted' + }, + + ] + } + } + } as SubmissionConfig +} as GlobalConfig; diff --git a/src/app/shared/testing/ng-component-outlet-directive-stub.ts b/src/app/shared/testing/ng-component-outlet-directive-stub.ts new file mode 100644 index 0000000000..ee3f65b460 --- /dev/null +++ b/src/app/shared/testing/ng-component-outlet-directive-stub.ts @@ -0,0 +1,10 @@ +import { Directive, Input } from '@angular/core'; + +/* tslint:disable:directive-class-suffix */ +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '[ngComponentOutlet]', +}) +export class NgComponentOutletDirectiveStub { + @Input() ngComponentOutlet: any; +} diff --git a/src/app/shared/testing/notifications-service-stub.ts b/src/app/shared/testing/notifications-service-stub.ts index 16588c2017..154c5b4351 100644 --- a/src/app/shared/testing/notifications-service-stub.ts +++ b/src/app/shared/testing/notifications-service-stub.ts @@ -1,44 +1,13 @@ -import {of as observableOf, Observable } from 'rxjs'; -import { INotification } from '../notifications/models/notification.model'; import { NotificationOptions } from '../notifications/models/notification-options.model'; export class NotificationsServiceStub { - success(title: any = observableOf(''), - content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), - html?: any): INotification { - return - } - - error(title: any = observableOf(''), - content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), - html?: any): INotification { - return - } - - info(title: any = observableOf(''), - content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), - html?: any): INotification { - return - } - - warning(title: any = observableOf(''), - content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), - html?: any): INotification { - return - } - - remove(notification: INotification) { - return - } - - removeAll() { - return - } + success = jasmine.createSpy('success'); + error = jasmine.createSpy('error'); + info = jasmine.createSpy('info'); + warning = jasmine.createSpy('warning'); + remove = jasmine.createSpy('remove'); + removeAll = jasmine.createSpy('removeAll'); private getDefaultOptions(): NotificationOptions { return new NotificationOptions(); diff --git a/src/app/shared/testing/platform-service-stub.ts b/src/app/shared/testing/platform-service-stub.ts deleted file mode 100644 index 39b9b5f4d3..0000000000 --- a/src/app/shared/testing/platform-service-stub.ts +++ /dev/null @@ -1,12 +0,0 @@ - -// declare a stub service -export class PlatformServiceStub { - - public get isBrowser(): boolean { - return true; - } - - public get isServer(): boolean { - return false; - } -} diff --git a/src/app/shared/testing/router-link-directive-stub.ts b/src/app/shared/testing/router-link-directive-stub.ts new file mode 100644 index 0000000000..ba52602536 --- /dev/null +++ b/src/app/shared/testing/router-link-directive-stub.ts @@ -0,0 +1,10 @@ +import { Directive, Input } from '@angular/core'; + +/* tslint:disable:directive-class-suffix */ +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '[routerLink]', +}) +export class RouterLinkDirectiveStub { + @Input() routerLink: any; +} diff --git a/src/app/shared/testing/search-service-stub.ts b/src/app/shared/testing/search-service-stub.ts index cbc0611a47..2a46e42ef5 100644 --- a/src/app/shared/testing/search-service-stub.ts +++ b/src/app/shared/testing/search-service-stub.ts @@ -40,4 +40,8 @@ export class SearchServiceStub { getFilterLabels() { return observableOf([]); } + + search() { + return observableOf({}); + } } diff --git a/src/app/shared/testing/sections-service-stub.ts b/src/app/shared/testing/sections-service-stub.ts new file mode 100644 index 0000000000..2110d71d8e --- /dev/null +++ b/src/app/shared/testing/sections-service-stub.ts @@ -0,0 +1,18 @@ +export class SectionsServiceStub { + + checkSectionErrors = jasmine.createSpy('checkSectionErrors'); + dispatchRemoveSectionErrors = jasmine.createSpy('dispatchRemoveSectionErrors'); + getSectionData = jasmine.createSpy('getSectionData'); + getSectionErrors = jasmine.createSpy('getSectionErrors'); + getSectionState = jasmine.createSpy('getSectionState'); + isSectionValid = jasmine.createSpy('isSectionValid'); + isSectionEnabled = jasmine.createSpy('isSectionEnabled'); + isSectionReadOnly = jasmine.createSpy('isSectionReadOnly'); + isSectionAvailable = jasmine.createSpy('isSectionAvailable'); + addSection = jasmine.createSpy('addSection'); + removeSection = jasmine.createSpy('removeSection'); + updateSectionData = jasmine.createSpy('updateSectionData'); + setSectionError = jasmine.createSpy('setSectionError'); + setSectionStatus = jasmine.createSpy('setSectionStatus'); + +} diff --git a/src/app/shared/testing/submission-json-patch-operations-service-stub.ts b/src/app/shared/testing/submission-json-patch-operations-service-stub.ts new file mode 100644 index 0000000000..858ded6328 --- /dev/null +++ b/src/app/shared/testing/submission-json-patch-operations-service-stub.ts @@ -0,0 +1,10 @@ +import { SubmissionPatchRequest } from '../../core/data/request.models'; + +export class SubmissionJsonPatchOperationsServiceStub { + protected linkPath = 'workspaceitems'; + protected patchRequestConstructor: SubmissionPatchRequest; + + jsonPatchByResourceType = jasmine.createSpy('jsonPatchByResourceType'); + jsonPatchByResourceID = jasmine.createSpy('jsonPatchByResourceID'); + +} diff --git a/src/app/shared/testing/submission-rest-service-stub.ts b/src/app/shared/testing/submission-rest-service-stub.ts new file mode 100644 index 0000000000..53b2341b50 --- /dev/null +++ b/src/app/shared/testing/submission-rest-service-stub.ts @@ -0,0 +1,22 @@ +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; + +import { RequestService } from '../../core/data/request.service'; +import { CoreState } from '../../core/core.reducers'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; + +export class SubmissionRestServiceStub { + protected linkPath = 'workspaceitems'; + protected requestService: RequestService; + protected store: Store; + protected halService: HALEndpointService; + + deleteById = jasmine.createSpy('deleteById'); + fetchRequest = jasmine.createSpy('fetchRequest'); + getDataById = jasmine.createSpy('getDataById'); + getDataByHref = jasmine.createSpy('getDataByHref'); + getEndpointByIDHref = jasmine.createSpy('getEndpointByIDHref'); + patchToEndpoint = jasmine.createSpy('patchToEndpoint'); + postToEndpoint = jasmine.createSpy('postToEndpoint').and.returnValue(observableOf({})); + submitData = jasmine.createSpy('submitData'); +} diff --git a/src/app/shared/testing/submission-service-stub.ts b/src/app/shared/testing/submission-service-stub.ts new file mode 100644 index 0000000000..a330fa3eee --- /dev/null +++ b/src/app/shared/testing/submission-service-stub.ts @@ -0,0 +1,33 @@ +export class SubmissionServiceStub { + + changeSubmissionCollection = jasmine.createSpy('changeSubmissionCollection'); + createSubmission = jasmine.createSpy('createSubmission'); + depositSubmission = jasmine.createSpy('depositSubmission'); + discardSubmission = jasmine.createSpy('discardSubmission'); + dispatchInit = jasmine.createSpy('dispatchInit'); + dispatchDeposit = jasmine.createSpy('dispatchDeposit'); + dispatchDiscard = jasmine.createSpy('dispatchDiscard'); + dispatchSave = jasmine.createSpy('dispatchSave'); + dispatchSaveForLater = jasmine.createSpy('dispatchSaveForLater'); + dispatchSaveSection = jasmine.createSpy('dispatchSaveSection'); + getActiveSectionId = jasmine.createSpy('getActiveSectionId'); + getSubmissionObject = jasmine.createSpy('getSubmissionObject'); + getSubmissionSections = jasmine.createSpy('getSubmissionSections'); + getDisabledSectionsList = jasmine.createSpy('getDisabledSectionsList'); + getSubmissionObjectLinkName = jasmine.createSpy('getSubmissionObjectLinkName'); + getSubmissionScope = jasmine.createSpy('getSubmissionScope'); + getSubmissionStatus = jasmine.createSpy('getSubmissionStatus'); + getSubmissionSaveProcessingStatus = jasmine.createSpy('getSubmissionSaveProcessingStatus'); + getSubmissionDepositProcessingStatus = jasmine.createSpy('getSubmissionDepositProcessingStatus'); + isSectionHidden = jasmine.createSpy('isSectionHidden'); + isSubmissionLoading = jasmine.createSpy('isSubmissionLoading'); + notifyNewSection = jasmine.createSpy('notifyNewSection'); + redirectToMyDSpace = jasmine.createSpy('redirectToMyDSpace'); + resetAllSubmissionObjects = jasmine.createSpy('resetAllSubmissionObjects'); + resetSubmissionObject = jasmine.createSpy('resetSubmissionObject'); + retrieveSubmission = jasmine.createSpy('retrieveSubmission'); + setActiveSection = jasmine.createSpy('setActiveSection'); + startAutoSave = jasmine.createSpy('startAutoSave'); + stopAutoSave = jasmine.createSpy('stopAutoSave'); + +} diff --git a/src/app/shared/testing/test-module.ts b/src/app/shared/testing/test-module.ts index 03d22640d3..8f59d76c87 100644 --- a/src/app/shared/testing/test-module.ts +++ b/src/app/shared/testing/test-module.ts @@ -1,5 +1,10 @@ -import { NgModule } from '@angular/core'; +import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from '@angular/core'; import { QueryParamsDirectiveStub } from './query-params-directive-stub'; +import { MySimpleItemActionComponent } from '../../+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec'; +import {CommonModule} from '@angular/common'; +import {SharedModule} from '../shared.module'; +import { RouterLinkDirectiveStub } from './router-link-directive-stub'; +import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive-stub'; /** * This module isn't used. It serves to prevent the AoT compiler @@ -8,8 +13,17 @@ import { QueryParamsDirectiveStub } from './query-params-directive-stub'; * See https://github.com/angular/angular/issues/13590 */ @NgModule({ + imports: [ + CommonModule, + SharedModule + ], declarations: [ - QueryParamsDirectiveStub + QueryParamsDirectiveStub, + MySimpleItemActionComponent, + RouterLinkDirectiveStub, + NgComponentOutletDirectiveStub + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA ] }) export class TestModule {} diff --git a/src/app/shared/testing/utils.ts b/src/app/shared/testing/utils.ts index 9343ae50fd..770a554439 100644 --- a/src/app/shared/testing/utils.ts +++ b/src/app/shared/testing/utils.ts @@ -30,3 +30,23 @@ export const createTestComponent = (html: string, type: { new(...args: any[]) fixture.detectChanges(); return fixture as ComponentFixture; }; + +/** + * Allows you to spy on a read only property + * + * @param obj + * The object to spy on + * @param prop + * The property to spy on + */ +export function spyOnOperator(obj: any, prop: string): any { + const oldProp = obj[prop]; + Object.defineProperty(obj, prop, { + configurable: true, + enumerable: true, + value: oldProp, + writable: true + }); + + return spyOn(obj, prop); +} diff --git a/src/app/shared/uploader/uploader.component.html b/src/app/shared/uploader/uploader.component.html index f01b1f7a78..2ec1321afc 100644 --- a/src/app/shared/uploader/uploader.component.html +++ b/src/app/shared/uploader/uploader.component.html @@ -19,7 +19,7 @@ (fileOver)="fileOverBase($event)" class="well ds-base-drop-zone mt-1 mb-3 text-muted">

    - {{dropMsg | translate}} {{'uploader.or' | translate}} + {{dropMsg | translate}} {{'uploader.or' | translate}}

    {{ uploader.progress }}% diff --git a/src/app/shared/uploader/uploader.component.scss b/src/app/shared/uploader/uploader.component.scss index 7e0f6fdd23..370c3ea280 100644 --- a/src/app/shared/uploader/uploader.component.scss +++ b/src/app/shared/uploader/uploader.component.scss @@ -10,7 +10,7 @@ } .ds-base-drop-zone p { - height: 42px; + min-height: $drop-zone-area-height; } .ds-document-drop-zone { diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index c43ac91082..641901b488 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -59,6 +59,11 @@ export class UploaderComponent { */ @Output() onCompleteItem: EventEmitter = new EventEmitter(); + /** + * The function to call on error occurred + */ + @Output() onUploadError: EventEmitter = new EventEmitter(); + public uploader: FileUploader; public uploaderId: string; public isOverBaseDropZone = observableOf(false); @@ -126,6 +131,10 @@ export class UploaderComponent { this.onCompleteItem.emit(responsePath); } }; + this.uploader.onErrorItem = (item: any, response: any, status: any, headers: any) => { + this.onUploadError.emit(null); + this.uploader.cancelAll(); + }; this.uploader.onProgressAll = () => this.onProgress(); this.uploader.onProgressItem = () => this.onProgress(); } diff --git a/src/app/shared/utils/auto-focus.directive.ts b/src/app/shared/utils/auto-focus.directive.ts new file mode 100644 index 0000000000..a2d860a8e1 --- /dev/null +++ b/src/app/shared/utils/auto-focus.directive.ts @@ -0,0 +1,28 @@ +import { Directive, AfterViewInit, ElementRef, Input } from '@angular/core'; +import { isNotEmpty } from '../empty.util'; + +/** + * Directive to set focus on an element when it is rendered + */ +@Directive({ + selector: '[dsAutoFocus]' +}) +export class AutoFocusDirective implements AfterViewInit { + + /** + * Optional input to specify which element in a component should get the focus + * If left empty, the component itself will get the focus + */ + @Input() autoFocusSelector: string = undefined; + + constructor(private el: ElementRef) { + } + + ngAfterViewInit() { + if (isNotEmpty(this.autoFocusSelector)) { + return this.el.nativeElement.querySelector(this.autoFocusSelector).focus(); + } else { + return this.el.nativeElement.focus(); + } + } +} diff --git a/src/app/shared/utils/click-outside.directive.ts b/src/app/shared/utils/click-outside.directive.ts index e8efdf2d7a..b9397c65e5 100644 --- a/src/app/shared/utils/click-outside.directive.ts +++ b/src/app/shared/utils/click-outside.directive.ts @@ -16,9 +16,11 @@ export class ClickOutsideDirective { constructor(private _elementRef: ElementRef) { } - @HostListener('document:click', ['$event.target']) - public onClick(targetElement) { - const clickedInside = this._elementRef.nativeElement.contains(targetElement); + @HostListener('document:click') + public onClick() { + const hostElement = this._elementRef.nativeElement; + const focusElement = hostElement.ownerDocument.activeElement; + const clickedInside = hostElement.contains(focusElement); if (!clickedInside) { this.dsClickOutside.emit(null); } diff --git a/src/app/shared/utils/debounce.directive.ts b/src/app/shared/utils/debounce.directive.ts index a84a2d379e..8830679e2b 100644 --- a/src/app/shared/utils/debounce.directive.ts +++ b/src/app/shared/utils/debounce.directive.ts @@ -25,11 +25,6 @@ export class DebounceDirective implements OnInit, OnDestroy { @Input() public dsDebounce = 500; - /** - * True if no changes have been made to the input field's value - */ - private isFirstChange = true; - /** * Subject to unsubscribe from */ @@ -46,11 +41,9 @@ export class DebounceDirective implements OnInit, OnDestroy { this.model.valueChanges.pipe( takeUntil(this.subject), debounceTime(this.dsDebounce), - distinctUntilChanged(),) + distinctUntilChanged()) .subscribe((modelValue) => { - if (this.isFirstChange) { - this.isFirstChange = false; - } else { + if (this.model.dirty) { this.onDebounce.emit(modelValue); } }); diff --git a/src/app/shared/utils/in-list-validator.directive.ts b/src/app/shared/utils/in-list-validator.directive.ts new file mode 100644 index 0000000000..42ff7da1fd --- /dev/null +++ b/src/app/shared/utils/in-list-validator.directive.ts @@ -0,0 +1,29 @@ +import { Directive, Input } from '@angular/core'; +import { FormControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; +import { inListValidator } from './validator.functions'; + +/** + * Directive for validating if a ngModel value is in a given list + */ +@Directive({ + selector: '[ngModel][dsInListValidator]', + // We add our directive to the list of existing validators + providers: [ + { provide: NG_VALIDATORS, useExisting: InListValidator, multi: true } + ] +}) +export class InListValidator implements Validator { + /** + * The list to look in + */ + @Input() + dsInListValidator: string[]; + + /** + * The function that checks if the form control's value is currently valid + * @param c The FormControl + */ + validate(c: FormControl): ValidationErrors | null { + return inListValidator(this.dsInListValidator)(c); + } +} diff --git a/src/app/shared/utils/object-ngfor.pipe.ts b/src/app/shared/utils/object-ngfor.pipe.ts new file mode 100644 index 0000000000..982e3342e0 --- /dev/null +++ b/src/app/shared/utils/object-ngfor.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Pipe that allows to iterate over an object and to access to entry key and value : + * + *
    + * {{obj.key}} - {{obj.value}} + *
    + * + */ +@Pipe({ + name: 'dsObjNgFor' +}) +export class ObjNgFor implements PipeTransform { + transform(value: any, args: any[] = null): any { + return Object.keys(value).map((key) => Object.assign({ key }, {value: value[key]})); + } +} diff --git a/src/app/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts new file mode 100644 index 0000000000..79efd1cb76 --- /dev/null +++ b/src/app/shared/utils/object-values-pipe.ts @@ -0,0 +1,18 @@ +import { PipeTransform, Pipe } from '@angular/core'; + +@Pipe({name: 'dsObjectValues'}) +/** + * Pipe for parsing all values of an object to an array of values + */ +export class ObjectValuesPipe implements PipeTransform { + + /** + * @param value An object + * @returns {any} Array with all values of the input object + */ + transform(value, args:string[]): any { + const values = []; + Object.values(value).forEach((v) => values.push(v)); + return values; + } +} diff --git a/src/app/shared/utils/validator.functions.ts b/src/app/shared/utils/validator.functions.ts new file mode 100644 index 0000000000..464a4f5487 --- /dev/null +++ b/src/app/shared/utils/validator.functions.ts @@ -0,0 +1,17 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; +import { isNotEmpty } from '../empty.util'; + +/** + * Returns a validator function to check if the control's value is in a given list + * @param list The list to look in + */ +export function inListValidator(list: string[]): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + const hasValue = isNotEmpty(control.value); + let inList = true; + if (isNotEmpty(list)) { + inList = list.indexOf(control.value) > -1; + } + return (hasValue && inList) ? null : { inList: { value: control.value } } + }; +} diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.html b/src/app/shared/view-mode-switch/view-mode-switch.component.html index fb5e51a095..8930475578 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.html +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.html @@ -6,7 +6,7 @@ routerLinkActive="active" [class.active]="currentMode === viewModeEnum.List" class="btn btn-secondary"> - + - +
    \ No newline at end of file diff --git a/src/app/submission/edit/submission-edit.component.html b/src/app/submission/edit/submission-edit.component.html new file mode 100644 index 0000000000..dcd8d84edc --- /dev/null +++ b/src/app/submission/edit/submission-edit.component.html @@ -0,0 +1,7 @@ +
    + +
    diff --git a/src/app/submission/edit/submission-edit.component.scss b/src/app/submission/edit/submission-edit.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts new file mode 100644 index 0000000000..5c9a247aa2 --- /dev/null +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -0,0 +1,120 @@ +import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { SubmissionEditComponent } from './submission-edit.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { SubmissionService } from '../submission.service'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service-stub'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; + +import { RouterStub } from '../../shared/testing/router-stub'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { mockSubmissionObject } from '../../shared/mocks/mock-submission'; +import { RemoteData } from '../../core/data/remote-data'; + +describe('SubmissionEditComponent Component', () => { + + let comp: SubmissionEditComponent; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let router: RouterStub; + + const submissionId = '826'; + const route: ActivatedRouteStub = new ActivatedRouteStub(); + const submissionObject: any = mockSubmissionObject; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([ + { path: ':id/edit', component: SubmissionEditComponent, pathMatch: 'full' }, + ]) + ], + declarations: [SubmissionEditComponent], + providers: [ + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: route }, + + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionEditComponent); + comp = fixture.componentInstance; + submissionServiceStub = TestBed.get(SubmissionService); + router = TestBed.get(Router); + }); + + afterEach(() => { + comp = null; + fixture = null; + router = null; + }); + + it('should init properly when a valid SubmissionObject has been retrieved', fakeAsync(() => { + + route.testParams = { id: submissionId }; + submissionServiceStub.retrieveSubmission.and.returnValue(observableOf( + new RemoteData( + false, + false, + true, + null, + submissionObject) + )); + + fixture.detectChanges(); + + expect(comp.submissionId).toBe(submissionId); + expect(comp.collectionId).toBe(submissionObject.collection.id); + expect(comp.selfUrl).toBe(submissionObject.self); + expect(comp.sections).toBe(submissionObject.sections); + expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); + + })); + + it('should redirect to mydspace when an empty SubmissionObject has been retrieved', fakeAsync(() => { + + route.testParams = { id: submissionId }; + submissionServiceStub.retrieveSubmission.and.returnValue(observableOf( + new RemoteData( + false, + false, + true, + null, + {}) + )); + + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalled(); + + })); + + it('should not has effects when an invalid SubmissionObject has been retrieved', fakeAsync(() => { + + route.testParams = { id: submissionId }; + submissionServiceStub.retrieveSubmission.and.returnValue(observableOf(null)); + + fixture.detectChanges(); + + expect(router.navigate).not.toHaveBeenCalled(); + expect(comp.collectionId).toBeUndefined(); + expect(comp.selfUrl).toBeUndefined(); + expect(comp.sections).toBeUndefined(); + expect(comp.submissionDefinition).toBeUndefined(); + })); + +}); diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts new file mode 100644 index 0000000000..60c8b9a7a3 --- /dev/null +++ b/src/app/submission/edit/submission-edit.component.ts @@ -0,0 +1,120 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; + +import { Subscription } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; +import { hasValue, isEmpty, isNotNull } from '../../shared/empty.util'; +import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { SubmissionService } from '../submission.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { Collection } from '../../core/shared/collection.model'; +import { RemoteData } from '../../core/data/remote-data'; + +/** + * This component allows to edit an existing workspaceitem/workflowitem. + */ +@Component({ + selector: 'ds-submission-edit', + styleUrls: ['./submission-edit.component.scss'], + templateUrl: './submission-edit.component.html' +}) +export class SubmissionEditComponent implements OnDestroy, OnInit { + + /** + * The collection id this submission belonging to + * @type {string} + */ + public collectionId: string; + + /** + * The list of submission's sections + * @type {WorkspaceitemSectionsObject} + */ + public sections: WorkspaceitemSectionsObject; + + /** + * The submission self url + * @type {string} + */ + public selfUrl: string; + + /** + * The configuration object that define this submission + * @type {SubmissionDefinitionsModel} + */ + public submissionDefinition: SubmissionDefinitionsModel; + + /** + * The submission id + * @type {string} + */ + public submissionId: string; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} changeDetectorRef + * @param {NotificationsService} notificationsService + * @param {ActivatedRoute} route + * @param {Router} router + * @param {SubmissionService} submissionService + * @param {TranslateService} translate + */ + constructor(private changeDetectorRef: ChangeDetectorRef, + private notificationsService: NotificationsService, + private route: ActivatedRoute, + private router: Router, + private submissionService: SubmissionService, + private translate: TranslateService) { + } + + /** + * Retrieve workspaceitem/workflowitem from server and initialize all instance variables + */ + ngOnInit() { + this.subs.push(this.route.paramMap.pipe( + switchMap((params: ParamMap) => this.submissionService.retrieveSubmission(params.get('id'))), + // NOTE new submission is retrieved on the browser side only, so get null on server side rendering + filter((submissionObjectRD: RemoteData) => isNotNull(submissionObjectRD)) + ).subscribe((submissionObjectRD: RemoteData) => { + if (submissionObjectRD.hasSucceeded) { + if (isEmpty(submissionObjectRD.payload)) { + this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); + this.router.navigate(['/mydspace']); + } else { + this.submissionId = submissionObjectRD.payload.id.toString(); + this.collectionId = (submissionObjectRD.payload.collection as Collection).id; + this.selfUrl = submissionObjectRD.payload.self; + this.sections = submissionObjectRD.payload.sections; + this.submissionDefinition = (submissionObjectRD.payload.submissionDefinition as SubmissionDefinitionsModel); + this.changeDetectorRef.detectChanges(); + } + } else { + if (submissionObjectRD.error.statusCode === 404) { + // redirect to not found page + this.router.navigate(['/404'], { skipLocationChange: true }); + } + // TODO handle generic error + } + })); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy() { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/submission/form/collection/submission-form-collection.component.html b/src/app/submission/form/collection/submission-form-collection.component.html new file mode 100644 index 0000000000..6547a3cc3c --- /dev/null +++ b/src/app/submission/form/collection/submission-form-collection.component.html @@ -0,0 +1,50 @@ +
    +
    +
    + + {{ 'submission.sections.general.collection' | translate }} + +
    + + + +
    +
    diff --git a/src/app/submission/form/collection/submission-form-collection.component.scss b/src/app/submission/form/collection/submission-form-collection.component.scss new file mode 100644 index 0000000000..f91f85677d --- /dev/null +++ b/src/app/submission/form/collection/submission-form-collection.component.scss @@ -0,0 +1,17 @@ +@import '../../../../styles/variables'; + +.scrollable-menu { + height: auto; + max-height: $dropdown-menu-max-height; + overflow-x: hidden; +} + +.collection-item { + border-bottom: $dropdown-border-width solid $dropdown-border-color; +} + +#collectionControlsDropdownMenu { + outline: 0; + left: 0 !important; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts new file mode 100644 index 0000000000..679500a670 --- /dev/null +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -0,0 +1,433 @@ +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement, SimpleChange } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { of as observableOf } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { Store } from '@ngrx/store'; + +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { mockSubmissionId, mockSubmissionRestResponse } from '../../../shared/mocks/mock-submission'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionFormCollectionComponent } from './submission-form-collection.component'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; +import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testing/submission-json-patch-operations-service-stub'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { Community } from '../../../core/shared/community.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { createTestComponent } from '../../../shared/testing/utils'; +import { cold } from 'jasmine-marbles'; + +const subcommunities = [Object.assign(new Community(), { + name: 'SubCommunity 1', + id: '123456789-1', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'SubCommunity 1' + }] +}), + Object.assign(new Community(), { + name: 'SubCommunity 1', + id: '123456789s-1', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'SubCommunity 1' + }] + }) +]; + +const mockCommunity1Collection1 = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: '1234567890-1', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }] +}); + +const mockCommunity1Collection2 = Object.assign(new Collection(), { + name: 'Community 1-Collection 2', + id: '1234567890-2', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 2' + }] +}); + +const mockCommunity2Collection1 = Object.assign(new Collection(), { + name: 'Community 2-Collection 1', + id: '1234567890-3', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 2-Collection 1' + }] +}); + +const mockCommunity2Collection2 = Object.assign(new Collection(), { + name: 'Community 2-Collection 2', + id: '1234567890-4', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 2-Collection 2' + }] +}); + +const mockCommunity = Object.assign(new Community(), { + name: 'Community 1', + id: '123456789-1', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1' + }], + collections: observableOf(new RemoteData(true, true, true, + undefined, new PaginatedList(new PageInfo(), [mockCommunity1Collection1, mockCommunity1Collection2]))), + subcommunities: observableOf(new RemoteData(true, true, true, + undefined, new PaginatedList(new PageInfo(), subcommunities))), +}); + +const mockCommunity2 = Object.assign(new Community(), { + name: 'Community 2', + id: '123456789-2', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 2' + }], + collections: observableOf(new RemoteData(true, true, true, + undefined, new PaginatedList(new PageInfo(), [mockCommunity2Collection1, mockCommunity2Collection2]))), + subcommunities: observableOf(new RemoteData(true, true, true, + undefined, new PaginatedList(new PageInfo(), []))), +}); + +const mockCommunityList = observableOf(new RemoteData(true, true, true, + undefined, new PaginatedList(new PageInfo(), [mockCommunity, mockCommunity2]))); + +const mockCollectionList = [ + { + communities: [ + { + id: '123456789-1', + name: 'Community 1' + } + ], + collection: { + id: '1234567890-1', + name: 'Community 1-Collection 1' + } + }, + { + communities: [ + { + id: '123456789-1', + name: 'Community 1' + } + ], + collection: { + id: '1234567890-2', + name: 'Community 1-Collection 2' + } + }, + { + communities: [ + { + id: '123456789-2', + name: 'Community 2' + } + ], + collection: { + id: '1234567890-3', + name: 'Community 2-Collection 1' + } + }, + { + communities: [ + { + id: '123456789-2', + name: 'Community 2' + } + ], + collection: { + id: '1234567890-4', + name: 'Community 2-Collection 2' + } + } +]; + +describe('SubmissionFormCollectionComponent Component', () => { + + let comp: SubmissionFormCollectionComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let jsonPatchOpServiceStub: SubmissionJsonPatchOperationsServiceStub; + + const submissionId = mockSubmissionId; + const collectionId = '1234567890-1'; + const definition = 'traditional'; + const submissionRestResponse = mockSubmissionRestResponse; + const searchedCollection = 'Community 2-Collection 2'; + + const communityDataService: any = jasmine.createSpyObj('communityDataService', { + findAll: jasmine.createSpy('findAll') + }); + const store: any = jasmine.createSpyObj('store', { + dispatch: jasmine.createSpy('dispatch'), + select: jasmine.createSpy('select') + }); + const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + replace: jasmine.createSpy('replace') + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + SubmissionFormCollectionComponent, + TestComponent + ], + providers: [ + { provide: SubmissionJsonPatchOperationsService, useClass: SubmissionJsonPatchOperationsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: CommunityDataService, useValue: communityDataService }, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + { provide: Store, useValue: store }, + ChangeDetectorRef, + SubmissionFormCollectionComponent + ], + schemas: [CUSTOM_ELEMENTS_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 SubmissionFormCollectionComponent', inject([SubmissionFormCollectionComponent], (app: SubmissionFormCollectionComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionFormCollectionComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + jsonPatchOpServiceStub = TestBed.get(SubmissionJsonPatchOperationsService); + comp.currentCollectionId = collectionId; + comp.currentDefinition = definition; + comp.submissionId = submissionId; + }); + + afterEach(() => { + comp = null; + compAsAny = null; + fixture = null; + submissionServiceStub = null; + jsonPatchOpServiceStub = null; + }); + + it('should init JsonPatchOperationPathCombiner', () => { + const expected = new JsonPatchOperationPathCombiner('sections', 'collection'); + + fixture.detectChanges(); + + expect(compAsAny.pathCombiner).toEqual(expected); + }); + + it('should init collection list properly', () => { + communityDataService.findAll.and.returnValue(mockCommunityList); + + comp.ngOnChanges({ + currentCollectionId: new SimpleChange(null, collectionId, true) + }); + + expect(comp.searchListCollection$).toBeObservable(cold('(ab)', { + a: [], + b: mockCollectionList + })); + + expect(comp.selectedCollectionName$).toBeObservable(cold('(ab|)', { + a: '', + b: 'Community 1-Collection 1' + })); + }); + + it('should show only the searched collection', () => { + comp.searchListCollection$ = observableOf(mockCollectionList); + fixture.detectChanges(); + + comp.searchField.setValue(searchedCollection); + fixture.detectChanges(); + + comp.searchListCollection$.pipe( + filter(() => !comp.disabled$.getValue()) + ).subscribe((list) => { + expect(list).toEqual([mockCollectionList[3]]); + }); + + }); + + it('should emit collectionChange event when selecting a new collection', () => { + spyOn(comp.searchField, 'reset').and.callThrough(); + spyOn(comp.collectionChange, 'emit').and.callThrough(); + jsonPatchOpServiceStub.jsonPatchByResourceID.and.returnValue(observableOf(submissionRestResponse)); + comp.ngOnInit(); + comp.onSelect(mockCollectionList[1]); + fixture.detectChanges(); + + expect(comp.searchField.reset).toHaveBeenCalled(); + expect(comp.collectionChange.emit).toHaveBeenCalledWith(submissionRestResponse[0]); + expect(submissionServiceStub.changeSubmissionCollection).toHaveBeenCalled(); + expect(comp.selectedCollectionId).toBe(mockCollectionList[1].collection.id); + expect(comp.selectedCollectionName$).toBeObservable(cold('(a|)', { + a: mockCollectionList[1].collection.name + })); + + }); + + it('should reset searchField when dropdown menu has been closed', () => { + spyOn(comp.searchField, 'reset').and.callThrough(); + comp.toggled(false); + + expect(comp.searchField.reset).toHaveBeenCalled(); + }); + + describe('', () => { + let dropdowBtn: DebugElement; + let dropdownMenu: DebugElement; + + beforeEach(() => { + + comp.searchListCollection$ = observableOf(mockCollectionList); + fixture.detectChanges(); + dropdowBtn = fixture.debugElement.query(By.css('#collectionControlsMenuButton')); + dropdownMenu = fixture.debugElement.query(By.css('#collectionControlsDropdownMenu')); + }); + + it('should have dropdown menu closed', () => { + + expect(dropdowBtn).not.toBeUndefined(); + expect(dropdownMenu.nativeElement.classList).not.toContain('show'); + + }); + + it('should display dropdown menu when click on dropdown button', fakeAsync(() => { + + spyOn(comp, 'onClose'); + dropdowBtn.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(comp.onClose).toHaveBeenCalled(); + expect(dropdownMenu.nativeElement.classList).toContain('show'); + expect(dropdownMenu.queryAll(By.css('.collection-item')).length).toBe(4); + }); + })); + + it('should trigger onSelect method when select a new collection from dropdown menu', fakeAsync(() => { + + spyOn(comp, 'onSelect'); + dropdowBtn.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + const secondLink: DebugElement = dropdownMenu.query(By.css('.collection-item:nth-child(2)')); + secondLink.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + + expect(comp.onSelect).toHaveBeenCalled(); + }); + })); + + it('should update searchField on input type', fakeAsync(() => { + + dropdowBtn.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + const input = fixture.debugElement.query(By.css('input.form-control')); + const el = input.nativeElement; + + expect(el.value).toBe(''); + + el.value = searchedCollection; + el.dispatchEvent(new Event('input')); + + fixture.detectChanges(); + + expect(fixture.componentInstance.searchField.value).toEqual(searchedCollection); + }); + })); + + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + collectionId = '1234567890-1'; + definitionId = 'traditional'; + submissionId = mockSubmissionId; + + onCollectionChange = () => { return; } + +} diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts new file mode 100644 index 0000000000..2fe424bd3f --- /dev/null +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -0,0 +1,304 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { FormControl } from '@angular/forms'; + +import { BehaviorSubject, combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + filter, + find, + flatMap, + map, + mergeMap, + reduce, + startWith +} from 'rxjs/operators'; + +import { Collection } from '../../../core/shared/collection.model'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { Community } from '../../../core/shared/community.model'; +import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; +import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; + +/** + * An interface to represent a collection entry + */ +interface CollectionListEntryItem { + id: string; + name: string; +} + +/** + * An interface to represent an entry in the collection list + */ +interface CollectionListEntry { + communities: CollectionListEntryItem[], + collection: CollectionListEntryItem +} + +/** + * This component allows to show the current collection the submission belonging to and to change it. + */ +@Component({ + selector: 'ds-submission-form-collection', + styleUrls: ['./submission-form-collection.component.scss'], + templateUrl: './submission-form-collection.component.html' +}) +export class SubmissionFormCollectionComponent implements OnChanges, OnInit { + + /** + * The current collection id this submission belonging to + * @type {string} + */ + @Input() currentCollectionId: string; + + /** + * The current configuration object that define this submission + * @type {SubmissionDefinitionsModel} + */ + @Input() currentDefinition: string; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId; + + /** + * An event fired when a different collection is selected. + * Event's payload equals to new SubmissionObject. + */ + @Output() collectionChange: EventEmitter = new EventEmitter(); + + /** + * A boolean representing if this dropdown button is disabled + * @type {BehaviorSubject} + */ + public disabled$ = new BehaviorSubject(true); + + /** + * The search form control + * @type {FormControl} + */ + public searchField: FormControl = new FormControl(); + + /** + * The collection list obtained from a search + * @type {Observable} + */ + public searchListCollection$: Observable; + + /** + * The selected collection id + * @type {string} + */ + public selectedCollectionId: string; + + /** + * The selected collection name + * @type {Observable} + */ + public selectedCollectionName$: Observable; + + /** + * The JsonPatchOperationPathCombiner object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + + /** + * A boolean representing if dropdown list is scrollable to the bottom + * @type {boolean} + */ + private scrollableBottom = false; + + /** + * A boolean representing if dropdown list is scrollable to the top + * @type {boolean} + */ + private scrollableTop = false; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} cdr + * @param {CommunityDataService} communityDataService + * @param {JsonPatchOperationsBuilder} operationsBuilder + * @param {SubmissionJsonPatchOperationsService} operationsService + * @param {SubmissionService} submissionService + */ + constructor(protected cdr: ChangeDetectorRef, + private communityDataService: CommunityDataService, + private operationsBuilder: JsonPatchOperationsBuilder, + private operationsService: SubmissionJsonPatchOperationsService, + private submissionService: SubmissionService) { + } + + /** + * Method called on mousewheel event, it prevent the page scroll + * when arriving at the top/bottom of dropdown menu + * + * @param event + * mousewheel event + */ + @HostListener('mousewheel', ['$event']) onMousewheel(event) { + if (event.wheelDelta > 0 && this.scrollableTop) { + event.preventDefault(); + } + if (event.wheelDelta < 0 && this.scrollableBottom) { + event.preventDefault(); + } + } + + /** + * Check if dropdown scrollbar is at the top or bottom of the dropdown list + * + * @param event + */ + onScroll(event) { + this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight); + this.scrollableTop = (event.target.scrollTop === 0); + } + + /** + * Initialize collection list + */ + ngOnChanges(changes: SimpleChanges) { + if (hasValue(changes.currentCollectionId) + && hasValue(changes.currentCollectionId.currentValue)) { + this.selectedCollectionId = this.currentCollectionId; + + // @TODO replace with search/top browse endpoint + // @TODO implement community/subcommunity hierarchy + const communities$ = this.communityDataService.findAll().pipe( + find((communities: RemoteData>) => isNotEmpty(communities.payload)), + mergeMap((communities: RemoteData>) => communities.payload.page)); + + const listCollection$ = communities$.pipe( + flatMap((communityData: Community) => { + return communityData.collections.pipe( + find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded), + mergeMap((collections: RemoteData>) => collections.payload.page), + filter((collectionData: Collection) => isNotEmpty(collectionData)), + map((collectionData: Collection) => ({ + communities: [{ id: communityData.id, name: communityData.name }], + collection: { id: collectionData.id, name: collectionData.name } + })) + ); + }), + reduce((acc: any, value: any) => [...acc, ...value], []), + startWith([]) + ); + + this.selectedCollectionName$ = communities$.pipe( + flatMap((communityData: Community) => { + return communityData.collections.pipe( + find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded), + mergeMap((collections: RemoteData>) => collections.payload.page), + filter((collectionData: Collection) => isNotEmpty(collectionData)), + filter((collectionData: Collection) => collectionData.id === this.selectedCollectionId), + map((collectionData: Collection) => collectionData.name) + ); + }), + startWith('') + ); + + const searchTerm$ = this.searchField.valueChanges.pipe( + debounceTime(200), + distinctUntilChanged(), + startWith('') + ); + + this.searchListCollection$ = combineLatest(searchTerm$, listCollection$).pipe( + map(([searchTerm, listCollection]) => { + this.disabled$.next(isEmpty(listCollection)); + if (isEmpty(searchTerm)) { + return listCollection; + } else { + return listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1).slice(0, 5) + } + })); + } + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', 'collection'); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + + /** + * Emit a [collectionChange] event when a new collection is selected from list + * + * @param event + * the selected [CollectionListEntryItem] + */ + onSelect(event) { + this.searchField.reset(); + this.disabled$.next(true); + this.operationsBuilder.replace(this.pathCombiner.getPath(), event.collection.id, true); + this.subs.push(this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + 'sections', + 'collection') + .subscribe((submissionObject: SubmissionObject[]) => { + this.selectedCollectionId = event.collection.id; + this.selectedCollectionName$ = observableOf(event.collection.name); + this.collectionChange.emit(submissionObject[0]); + this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id); + this.disabled$.next(false); + this.cdr.detectChanges(); + }) + ); + } + + /** + * Reset search form control on dropdown menu close + */ + onClose() { + this.searchField.reset(); + } + + /** + * Reset search form control when dropdown menu is closed + * + * @param isOpen + * Representing if the dropdown menu is open or not. + */ + toggled(isOpen: boolean) { + if (!isOpen) { + this.searchField.reset(); + } + } +} diff --git a/src/app/submission/form/footer/submission-form-footer.component.html b/src/app/submission/form/footer/submission-form-footer.component.html new file mode 100644 index 0000000000..0d58456b24 --- /dev/null +++ b/src/app/submission/form/footer/submission-form-footer.component.html @@ -0,0 +1,49 @@ +
    +
    + +
    +
    +
    +
    Saving...
    +
    Depositing...
    +
    +
    +
    + + + +
    +
    + + + + + + diff --git a/src/app/submission/form/footer/submission-form-footer.component.scss b/src/app/submission/form/footer/submission-form-footer.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/form/footer/submission-form-footer.component.spec.ts b/src/app/submission/form/footer/submission-form-footer.component.spec.ts new file mode 100644 index 0000000000..5fbfd84cb8 --- /dev/null +++ b/src/app/submission/form/footer/submission-form-footer.component.spec.ts @@ -0,0 +1,239 @@ +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, inject, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { cold, hot } from 'jasmine-marbles'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { Store } from '@ngrx/store'; + +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { mockSubmissionId } from '../../../shared/mocks/mock-submission'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionRestServiceStub } from '../../../shared/testing/submission-rest-service-stub'; +import { SubmissionFormFooterComponent } from './submission-form-footer.component'; +import { SubmissionRestService } from '../../../core/submission/submission-rest.service'; +import { createTestComponent } from '../../../shared/testing/utils'; + +describe('SubmissionFormFooterComponent Component', () => { + + let comp: SubmissionFormFooterComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let submissionRestServiceStub: SubmissionRestServiceStub; + + const submissionId = mockSubmissionId; + + const store: any = jasmine.createSpyObj('store', { + dispatch: jasmine.createSpy('dispatch'), + select: jasmine.createSpy('select') + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + SubmissionFormFooterComponent, + TestComponent + ], + providers: [ + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: SubmissionRestService, useClass: SubmissionRestServiceStub }, + { provide: Store, useValue: store }, + ChangeDetectorRef, + NgbModal, + SubmissionFormFooterComponent + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + testFixture.detectChanges(); + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create SubmissionFormFooterComponent', inject([SubmissionFormFooterComponent], (app: SubmissionFormFooterComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionFormFooterComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + submissionRestServiceStub = TestBed.get(SubmissionRestService); + comp.submissionId = submissionId; + + }); + + afterEach(() => { + comp = null; + compAsAny = null; + fixture = null; + submissionServiceStub = null; + submissionRestServiceStub = null; + }); + + describe('ngOnChanges', () => { + beforeEach(() => { + submissionServiceStub.getSubmissionStatus.and.returnValue(hot('-a-b', { + a: false, + b: true + })); + + submissionServiceStub.getSubmissionSaveProcessingStatus.and.returnValue(hot('-a-b', { + a: false, + b: true + })); + + submissionServiceStub.getSubmissionDepositProcessingStatus.and.returnValue(hot('-a-b', { + a: false, + b: true + })); + }); + + it('should set submissionIsInvalid properly', () => { + + const expected = cold('-c-d', { + c: true, + d: false + }); + + comp.ngOnChanges({ + submissionId: new SimpleChange(null, submissionId, true) + }); + + fixture.detectChanges(); + + expect(compAsAny.submissionIsInvalid).toBeObservable(expected); + }); + + it('should set processingSaveStatus properly', () => { + + const expected = cold('-c-d', { + c: false, + d: true + }); + + comp.ngOnChanges({ + submissionId: new SimpleChange(null, submissionId, true) + }); + + fixture.detectChanges(); + + expect(comp.processingSaveStatus).toBeObservable(expected); + }); + + it('should set processingDepositStatus properly', () => { + + const expected = cold('-c-d', { + c: false, + d: true + }); + + comp.ngOnChanges({ + submissionId: new SimpleChange(null, submissionId, true) + }); + + fixture.detectChanges(); + + expect(comp.processingDepositStatus).toBeObservable(expected); + }); + }); + + it('should call dispatchSave on save', () => { + + comp.save(null); + fixture.detectChanges(); + + expect(submissionServiceStub.dispatchSave).toHaveBeenCalledWith(submissionId); + }); + + it('should call dispatchSaveForLater on save for later', () => { + + comp.saveLater(null); + fixture.detectChanges(); + + expect(submissionServiceStub.dispatchSaveForLater).toHaveBeenCalledWith(submissionId); + }); + + it('should call dispatchDeposit on save', () => { + + comp.deposit(null); + fixture.detectChanges(); + + expect(submissionServiceStub.dispatchDeposit).toHaveBeenCalledWith(submissionId); + }); + + it('should call dispatchDiscard on discard confirmation', fakeAsync(() => { + comp.showDepositAndDiscard = observableOf(true); + fixture.detectChanges(); + const modalBtn = fixture.debugElement.query(By.css('.btn-danger')); + + modalBtn.nativeElement.click(); + fixture.detectChanges(); + + const confirmBtn: any = ((document as any).querySelector('.btn-danger:nth-child(2)')); + confirmBtn.click(); + + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(submissionServiceStub.dispatchDiscard).toHaveBeenCalledWith(submissionId); + }); + })); + + it('should have deposit button disabled when submission is not valid', () => { + comp.showDepositAndDiscard = observableOf(true); + compAsAny.submissionIsInvalid = observableOf(true); + fixture.detectChanges(); + const depositBtn: any = fixture.debugElement.query(By.css('.btn-primary')); + + expect(depositBtn.nativeElement.disabled).toBeTruthy(); + }); + + it('should not have deposit button disabled when submission is valid', () => { + comp.showDepositAndDiscard = observableOf(true); + compAsAny.submissionIsInvalid = observableOf(false); + fixture.detectChanges(); + const depositBtn: any = fixture.debugElement.query(By.css('.btn-primary')); + + expect(depositBtn.nativeElement.disabled).toBeFalsy(); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + submissionId = mockSubmissionId; + +} diff --git a/src/app/submission/form/footer/submission-form-footer.component.ts b/src/app/submission/form/footer/submission-form-footer.component.ts new file mode 100644 index 0000000000..4f4e355397 --- /dev/null +++ b/src/app/submission/form/footer/submission-form-footer.component.ts @@ -0,0 +1,112 @@ +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { SubmissionRestService } from '../../../core/submission/submission-rest.service'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; +import { isNotEmpty } from '../../../shared/empty.util'; + +/** + * This component represents submission form footer bar. + */ +@Component({ + selector: 'ds-submission-form-footer', + styleUrls: ['./submission-form-footer.component.scss'], + templateUrl: './submission-form-footer.component.html' +}) +export class SubmissionFormFooterComponent implements OnChanges { + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * A boolean representing if a submission deposit operation is pending + * @type {Observable} + */ + public processingDepositStatus: Observable; + + /** + * A boolean representing if a submission save operation is pending + * @type {Observable} + */ + public processingSaveStatus: Observable; + + /** + * A boolean representing if showing deposit and discard buttons + * @type {Observable} + */ + public showDepositAndDiscard: Observable; + + /** + * A boolean representing if submission form is valid or not + * @type {Observable} + */ + private submissionIsInvalid: Observable = observableOf(true); + + /** + * Initialize instance variables + * + * @param {NgbModal} modalService + * @param {SubmissionRestService} restService + * @param {SubmissionService} submissionService + */ + constructor(private modalService: NgbModal, + private restService: SubmissionRestService, + private submissionService: SubmissionService) { + } + + /** + * Initialize all instance variables + */ + ngOnChanges(changes: SimpleChanges) { + if (isNotEmpty(this.submissionId)) { + this.submissionIsInvalid = this.submissionService.getSubmissionStatus(this.submissionId).pipe( + map((isValid: boolean) => isValid === false) + ); + + this.processingSaveStatus = this.submissionService.getSubmissionSaveProcessingStatus(this.submissionId); + this.processingDepositStatus = this.submissionService.getSubmissionDepositProcessingStatus(this.submissionId); + this.showDepositAndDiscard = observableOf(this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem); + } + } + + /** + * Dispatch a submission save action + */ + save(event) { + this.submissionService.dispatchSave(this.submissionId); + } + + /** + * Dispatch a submission save for later action + */ + saveLater(event) { + this.submissionService.dispatchSaveForLater(this.submissionId); + } + + /** + * Dispatch a submission deposit action + */ + public deposit(event) { + this.submissionService.dispatchDeposit(this.submissionId); + } + + /** + * Dispatch a submission discard action + */ + public confirmDiscard(content) { + this.modalService.open(content).result.then( + (result) => { + if (result === 'ok') { + this.submissionService.dispatchDiscard(this.submissionId) + } + } + ); + } +} diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.html b/src/app/submission/form/section-add/submission-form-section-add.component.html new file mode 100644 index 0000000000..939f23209a --- /dev/null +++ b/src/app/submission/form/section-add/submission-form-section-add.component.html @@ -0,0 +1,24 @@ +
    + +
    + + +
    +
    diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.scss b/src/app/submission/form/section-add/submission-form-section-add.component.scss new file mode 100644 index 0000000000..628f0f5633 --- /dev/null +++ b/src/app/submission/form/section-add/submission-form-section-add.component.scss @@ -0,0 +1,9 @@ +@import '../../../../styles/variables'; + +.dropdown-toggle::after { + display:none +} + +.sections-dropdown-menu { + z-index: $submission-header-z-index; +} diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.spec.ts b/src/app/submission/form/section-add/submission-form-section-add.component.spec.ts new file mode 100644 index 0000000000..236bd6de9b --- /dev/null +++ b/src/app/submission/form/section-add/submission-form-section-add.component.spec.ts @@ -0,0 +1,219 @@ +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { Store } from '@ngrx/store'; + +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { mockSubmissionCollectionId, mockSubmissionId } from '../../../shared/mocks/mock-submission'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionFormSectionAddComponent } from './submission-form-section-add.component'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service-stub'; +import { SectionsService } from '../../sections/sections.service'; +import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub'; +import { HostWindowService } from '../../../shared/host-window.service'; +import { createTestComponent } from '../../../shared/testing/utils'; + +const mockAvailableSections: any = [ + { + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/newsectionone', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.describe.newsectionone', + id: 'newsectionone', + sectionType: 'submission-form' + }, + { + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/newsectiontwo', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.describe.newsectiontwo', + id: 'newsectiontwo', + sectionType: 'submission-form' + } +]; + +describe('SubmissionFormSectionAddComponent Component', () => { + + let comp: SubmissionFormSectionAddComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let sectionsServiceStub: SectionsServiceStub; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + + const store: any = jasmine.createSpyObj('store', { + dispatch: jasmine.createSpy('dispatch'), + select: jasmine.createSpy('select') + }); + + const window = new HostWindowServiceStub(800); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + SubmissionFormSectionAddComponent, + TestComponent + ], + providers: [ + { provide: HostWindowService, useValue: window }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: Store, useValue: store }, + ChangeDetectorRef, + SubmissionFormSectionAddComponent + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + testFixture.detectChanges(); + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create SubmissionFormSectionAddComponent', inject([SubmissionFormSectionAddComponent], (app: SubmissionFormSectionAddComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionFormSectionAddComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + sectionsServiceStub = TestBed.get(SectionsService); + comp.submissionId = submissionId; + comp.collectionId = collectionId; + + }); + + afterEach(() => { + comp = null; + compAsAny = null; + fixture = null; + submissionServiceStub = null; + sectionsServiceStub = null; + }); + + it('should init sectionList properly', () => { + submissionServiceStub.getDisabledSectionsList.and.returnValue(observableOf(mockAvailableSections)); + + fixture.detectChanges(); + + comp.sectionList$.subscribe((list) => { + expect(list).toEqual(mockAvailableSections); + }); + + comp.hasSections$.subscribe((hasSections) => { + expect(hasSections).toEqual(true); + }) + }); + + it('should call addSection', () => { + submissionServiceStub.getDisabledSectionsList.and.returnValue(observableOf(mockAvailableSections)); + + comp.addSection(mockAvailableSections[1].id); + + fixture.detectChanges(); + + expect(sectionsServiceStub.addSection).toHaveBeenCalledWith(submissionId, mockAvailableSections[1].id); + + }); + + describe('', () => { + let dropdowBtn: DebugElement; + let dropdownMenu: DebugElement; + + beforeEach(() => { + + submissionServiceStub.getDisabledSectionsList.and.returnValue(observableOf(mockAvailableSections)); + comp.ngOnInit(); + fixture.detectChanges(); + dropdowBtn = fixture.debugElement.query(By.css('#sectionControls')); + dropdownMenu = fixture.debugElement.query(By.css('.sections-dropdown-menu')); + }); + + it('should have dropdown menu closed', () => { + + expect(dropdowBtn).not.toBeUndefined(); + expect(dropdownMenu.nativeElement.classList).not.toContain('show'); + + }); + + it('should display dropdown menu when click on dropdown button', fakeAsync(() => { + + dropdowBtn.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(dropdownMenu.nativeElement.classList).toContain('show'); + + expect(dropdownMenu.queryAll(By.css('.dropdown-item')).length).toBe(2); + }); + + })); + + it('should trigger onSelect method when select a new collection from dropdown menu', fakeAsync(() => { + spyOn(comp, 'addSection'); + dropdowBtn.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + const secondLink: DebugElement = dropdownMenu.query(By.css('.dropdown-item:nth-child(2)')); + secondLink.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + + expect(comp.addSection).toHaveBeenCalled(); + }); + + })); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + collectionId = mockSubmissionCollectionId; + submissionId = mockSubmissionId; + +} diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.ts b/src/app/submission/form/section-add/submission-form-section-add.component.ts new file mode 100644 index 0000000000..48ba07dad1 --- /dev/null +++ b/src/app/submission/form/section-add/submission-form-section-add.component.ts @@ -0,0 +1,73 @@ +import { Component, Input, OnInit, } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { SectionsService } from '../../sections/sections.service'; +import { HostWindowService } from '../../../shared/host-window.service'; +import { SubmissionService } from '../../submission.service'; +import { SectionDataObject } from '../../sections/models/section-data.model'; + +/** + * This component allow to add any new section to submission form + */ +@Component({ + selector: 'ds-submission-form-section-add', + styleUrls: [ './submission-form-section-add.component.scss' ], + templateUrl: './submission-form-section-add.component.html' +}) +export class SubmissionFormSectionAddComponent implements OnInit { + + /** + * The collection id this submission belonging to + * @type {string} + */ + @Input() collectionId: string; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * The possible section list to add + * @type {Observable} + */ + public sectionList$: Observable; + + /** + * A boolean representing if there are available sections to add + * @type {Observable} + */ + public hasSections$: Observable; + + /** + * Initialize instance variables + * + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {HostWindowService} windowService + */ + constructor(private sectionService: SectionsService, + private submissionService: SubmissionService, + public windowService: HostWindowService) { + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.sectionList$ = this.submissionService.getDisabledSectionsList(this.submissionId); + this.hasSections$ = this.sectionList$.pipe( + map((list: SectionDataObject[]) => list.length > 0) + ) + } + + /** + * Dispatch an action to add a new section + */ + addSection(sectionId) { + this.sectionService.addSection(this.submissionId, sectionId); + } +} diff --git a/src/app/submission/form/submission-form.component.html b/src/app/submission/form/submission-form.component.html new file mode 100644 index 0000000000..7376b1e10b --- /dev/null +++ b/src/app/submission/form/submission-form.component.html @@ -0,0 +1,35 @@ +
    +
    + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + + + +
    + +
    diff --git a/src/app/submission/form/submission-form.component.scss b/src/app/submission/form/submission-form.component.scss new file mode 100644 index 0000000000..c9b6872146 --- /dev/null +++ b/src/app/submission/form/submission-form.component.scss @@ -0,0 +1,21 @@ +@import '../../../styles/variables'; + +.submission-form-header { + background-color: rgba($white, .97); + padding: ($spacer / 2) 0 ($spacer / 2) 0; + top: 0; + z-index: $submission-header-z-index; +} + +.submission-form-header-item { + flex-grow: 1; +} + +.submission-form-footer { + border-radius: $card-border-radius; + bottom: 0; + background-color: $gray-400; + padding: $spacer / 2; + z-index: $submission-footer-z-index; +} + diff --git a/src/app/submission/form/submission-form.component.spec.ts b/src/app/submission/form/submission-form.component.spec.ts new file mode 100644 index 0000000000..c8e10da518 --- /dev/null +++ b/src/app/submission/form/submission-form.component.spec.ts @@ -0,0 +1,212 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; + +import { SubmissionServiceStub } from '../../shared/testing/submission-service-stub'; +import { + mockSectionsData, + mockSectionsList, + mockSubmissionCollectionId, + mockSubmissionDefinition, + mockSubmissionId, + mockSubmissionObjectNew, + mockSubmissionSelfUrl, + mockSubmissionState +} from '../../shared/mocks/mock-submission'; +import { SubmissionService } from '../submission.service'; +import { SubmissionFormComponent } from './submission-form.component'; +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'; + +describe('SubmissionFormComponent Component', () => { + + let comp: SubmissionFormComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let authServiceStub: AuthServiceStub; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const submissionObjectNew: any = mockSubmissionObjectNew; + const submissionDefinition: any = mockSubmissionDefinition; + const submissionState: any = Object.assign({}, mockSubmissionState); + const selfUrl: any = mockSubmissionSelfUrl; + const sectionsList: any = mockSectionsList; + const sectionsData: any = mockSectionsData; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [ + SubmissionFormComponent, + TestComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + ChangeDetectorRef, + SubmissionFormComponent + ], + 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 SubmissionFormComponent', inject([SubmissionFormComponent], (app: SubmissionFormComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionFormComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + authServiceStub = TestBed.get(AuthService); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should not has effect when collectionId and submissionId are undefined', () => { + + fixture.detectChanges(); + + expect(compAsAny.isActive).toBeTruthy(); + expect(compAsAny.submissionSections).toBeUndefined(); + comp.loading.subscribe((loading) => { + expect(loading).toBeTruthy(); + }); + + expect(compAsAny.subs).toEqual([]); + expect(submissionServiceStub.startAutoSave).not.toHaveBeenCalled(); + }); + + it('should init properly when collectionId and submissionId are defined', () => { + comp.collectionId = collectionId; + comp.submissionId = submissionId; + comp.submissionDefinition = submissionDefinition; + comp.selfUrl = selfUrl; + comp.sections = sectionsData; + + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + submissionServiceStub.getSubmissionSections.and.returnValue(observableOf(sectionsList)); + spyOn(authServiceStub, 'buildAuthHeader').and.returnValue('token'); + + comp.ngOnChanges({ + collectionId: new SimpleChange(null, collectionId, true), + submissionId: new SimpleChange(null, submissionId, true) + }); + fixture.detectChanges(); + + comp.loading.subscribe((loading) => { + expect(loading).toBeFalsy(); + }); + + comp.submissionSections.subscribe((submissionSections) => { + expect(submissionSections).toEqual(sectionsList); + }); + + expect(submissionServiceStub.dispatchInit).toHaveBeenCalledWith( + collectionId, + submissionId, + selfUrl, + submissionDefinition, + sectionsData, + null); + expect(submissionServiceStub.startAutoSave).toHaveBeenCalled(); + }); + + it('should update properly on collection change', () => { + comp.collectionId = collectionId; + comp.submissionId = submissionId; + comp.submissionDefinition = submissionDefinition; + comp.selfUrl = selfUrl; + comp.sections = sectionsData; + + comp.onCollectionChange(submissionObjectNew); + + fixture.detectChanges(); + + expect(comp.collectionId).toEqual(submissionObjectNew.collection.id); + expect(comp.submissionDefinition).toEqual(submissionObjectNew.submissionDefinition); + expect(comp.definitionId).toEqual(submissionObjectNew.submissionDefinition.name); + expect(comp.sections).toEqual(submissionObjectNew.sections); + + expect(submissionServiceStub.resetSubmissionObject).toHaveBeenCalledWith( + submissionObjectNew.collection.id, + submissionId, + selfUrl, + submissionObjectNew.submissionDefinition, + submissionObjectNew.sections); + }); + + it('should update only collection id on collection change when submission definition is not changed', () => { + comp.collectionId = collectionId; + comp.submissionId = submissionId; + comp.definitionId = 'traditional'; + comp.submissionDefinition = submissionDefinition; + comp.selfUrl = selfUrl; + comp.sections = sectionsData; + + comp.onCollectionChange({ + collection: { + id: '45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb' + }, + submissionDefinition: { + name: 'traditional' + } + } as any); + + fixture.detectChanges(); + + expect(comp.collectionId).toEqual('45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb'); + expect(submissionServiceStub.resetSubmissionObject).not.toHaveBeenCalled() + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + collectionId = mockSubmissionCollectionId; + selfUrl = mockSubmissionSelfUrl; + submissionDefinition = mockSubmissionDefinition; + submissionId = mockSubmissionId; + +} diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts new file mode 100644 index 0000000000..b592972839 --- /dev/null +++ b/src/app/submission/form/submission-form.component.ts @@ -0,0 +1,220 @@ +import { ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; + +import { of as observableOf, Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map } from 'rxjs/operators'; + +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { SubmissionObjectEntry } from '../objects/submission-objects.reducer'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; +import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { SubmissionService } from '../submission.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { SectionDataObject } from '../sections/models/section-data.model'; +import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { Collection } from '../../core/shared/collection.model'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; + +/** + * This component represents the submission form. + */ +@Component({ + selector: 'ds-submission-form', + styleUrls: ['./submission-form.component.scss'], + templateUrl: './submission-form.component.html', +}) +export class SubmissionFormComponent implements OnChanges, OnDestroy { + + /** + * The collection id this submission belonging to + * @type {string} + */ + @Input() collectionId: string; + + /** + * The list of submission's sections + * @type {WorkspaceitemSectionsObject} + */ + @Input() sections: WorkspaceitemSectionsObject; + + /** + * The submission self url + * @type {string} + */ + @Input() selfUrl: string; + + /** + * The configuration object that define this submission + * @type {SubmissionDefinitionsModel} + */ + @Input() submissionDefinition: SubmissionDefinitionsModel; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * The configuration id that define this submission + * @type {string} + */ + public definitionId: string; + + /** + * A boolean representing if a submission form is pending + * @type {Observable} + */ + public loading: Observable = observableOf(true); + + /** + * Observable of the list of submission's sections + * @type {Observable} + */ + public submissionSections: Observable; + + /** + * The uploader configuration options + * @type {UploaderOptions} + */ + public uploadFilesOptions: UploaderOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + + /** + * A boolean representing if component is active + * @type {boolean} + */ + protected isActive: boolean; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {AuthService} authService + * @param {ChangeDetectorRef} changeDetectorRef + * @param {HALEndpointService} halService + * @param {SubmissionService} submissionService + */ + constructor( + private authService: AuthService, + private changeDetectorRef: ChangeDetectorRef, + private halService: HALEndpointService, + private submissionService: SubmissionService) { + this.isActive = true; + } + + /** + * Initialize all instance variables and retrieve form configuration + */ + ngOnChanges(changes: SimpleChanges) { + if (this.collectionId && this.submissionId) { + this.isActive = true; + + // retrieve submission's section list + this.submissionSections = this.submissionService.getSubmissionObject(this.submissionId).pipe( + filter(() => this.isActive), + map((submission: SubmissionObjectEntry) => submission.isLoading), + map((isLoading: boolean) => isLoading), + distinctUntilChanged(), + flatMap((isLoading: boolean) => { + if (!isLoading) { + return this.getSectionsList(); + } else { + return observableOf([]) + } + })); + + // check if is submission loading + this.loading = this.submissionService.getSubmissionObject(this.submissionId).pipe( + filter(() => this.isActive), + map((submission: SubmissionObjectEntry) => submission.isLoading), + map((isLoading: boolean) => isLoading), + distinctUntilChanged()); + + // init submission state + this.subs.push( + this.halService.getEndpoint('workspaceitems').pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged()) + .subscribe((endpointURL) => { + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + this.uploadFilesOptions.url = endpointURL.concat(`/${this.submissionId}`); + this.definitionId = this.submissionDefinition.name; + this.submissionService.dispatchInit( + this.collectionId, + this.submissionId, + this.selfUrl, + this.submissionDefinition, + this.sections, + null); + this.changeDetectorRef.detectChanges(); + }) + ); + + // start auto save + this.submissionService.startAutoSave(this.submissionId); + } + } + + /** + * Unsubscribe from all subscriptions, destroy instance variables + * and reset submission state + */ + ngOnDestroy() { + this.isActive = false; + this.submissionService.stopAutoSave(); + this.submissionService.resetAllSubmissionObjects(); + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + /** + * On collection change reset submission state in case of it has a different + * submission definition + * + * @param submissionObject + * new submission object + */ + onCollectionChange(submissionObject: SubmissionObject) { + this.collectionId = (submissionObject.collection as Collection).id; + if (this.definitionId !== (submissionObject.submissionDefinition as SubmissionDefinitionsModel).name) { + this.sections = submissionObject.sections; + this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); + this.definitionId = this.submissionDefinition.name; + this.submissionService.resetSubmissionObject( + this.collectionId, + this.submissionId, + submissionObject.self, + this.submissionDefinition, + this.sections); + } else { + this.changeDetectorRef.detectChanges(); + } + } + + /** + * Check if submission form is loading + */ + isLoading(): Observable { + return this.loading; + } + + /** + * Check if submission form is loading + */ + protected getSectionsList(): Observable { + return this.submissionService.getSubmissionSections(this.submissionId).pipe( + filter((sections: SectionDataObject[]) => isNotEmpty(sections)), + map((sections: SectionDataObject[]) => sections)); + } +} diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.html b/src/app/submission/form/submission-upload-files/submission-upload-files.component.html new file mode 100644 index 0000000000..cf916fb413 --- /dev/null +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts b/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts new file mode 100644 index 0000000000..60a572df54 --- /dev/null +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts @@ -0,0 +1,218 @@ +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Store } from '@ngrx/store'; + +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { + mockSectionsData, + mockSubmissionCollectionId, + mockSubmissionId, + mockSubmissionObject, + mockUploadResponse1ParsedErrors, + mockUploadResponse2Errors, + mockUploadResponse2ParsedErrors +} from '../../../shared/mocks/mock-submission'; +import { SubmissionService } from '../../submission.service'; + +import { SectionsServiceStub } from '../../../shared/testing/sections-service-stub'; +import { SectionsService } from '../../sections/sections.service'; +import { SubmissionUploadFilesComponent } from './submission-upload-files.component'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { getMockTranslateService } from '../../../shared/mocks/mock-translate.service'; +import { cold, hot } from 'jasmine-marbles'; +import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testing/submission-json-patch-operations-service-stub'; +import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; +import { SharedModule } from '../../../shared/shared.module'; +import { createTestComponent } from '../../../shared/testing/utils'; + +describe('SubmissionUploadFilesComponent Component', () => { + + let comp: SubmissionUploadFilesComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let sectionsServiceStub: SectionsServiceStub; + let notificationsServiceStub: NotificationsServiceStub; + let translateService: any; + + const submissionJsonPatchOperationsServiceStub = new SubmissionJsonPatchOperationsServiceStub(); + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const uploadRestResponse: any = mockSubmissionObject; + + const store: any = jasmine.createSpyObj('store', { + dispatch: jasmine.createSpy('dispatch'), + select: jasmine.createSpy('select') + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + SharedModule, + TranslateModule.forRoot() + ], + declarations: [ + SubmissionUploadFilesComponent, + TestComponent + ], + providers: [ + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: SubmissionJsonPatchOperationsService, useValue: submissionJsonPatchOperationsServiceStub }, + { provide: Store, useValue: store }, + ChangeDetectorRef, + SubmissionUploadFilesComponent + ], + schemas: [CUSTOM_ELEMENTS_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 SubmissionUploadFilesComponent', inject([SubmissionUploadFilesComponent], (app: SubmissionUploadFilesComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionUploadFilesComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + sectionsServiceStub = TestBed.get(SectionsService); + notificationsServiceStub = TestBed.get(NotificationsService); + translateService = TestBed.get(TranslateService); + comp.submissionId = submissionId; + comp.collectionId = collectionId; + comp.sectionId = 'upload'; + comp.uploadFilesOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + + }); + + afterEach(() => { + comp = null; + compAsAny = null; + fixture = null; + submissionServiceStub = null; + sectionsServiceStub = null; + notificationsServiceStub = null; + translateService = null; + }); + + it('should init uploadEnabled properly', () => { + sectionsServiceStub.isSectionAvailable.and.returnValue(hot('-a-b', { + a: false, + b: true + })); + + const expected = cold('-c-d', { + c: false, + d: true + }); + + comp.ngOnChanges(); + fixture.detectChanges(); + + expect(compAsAny.uploadEnabled).toBeObservable(expected); + }); + + it('should show a success notification and call updateSectionData on upload complete', () => { + + const expectedErrors: any = mockUploadResponse1ParsedErrors; + compAsAny.uploadEnabled = observableOf(true); + fixture.detectChanges(); + + comp.onCompleteItem(Object.assign({}, uploadRestResponse, { sections: mockSectionsData })); + + Object.keys(mockSectionsData).forEach((sectionId) => { + expect(sectionsServiceStub.updateSectionData).toHaveBeenCalledWith( + submissionId, + sectionId, + mockSectionsData[sectionId], + expectedErrors[sectionId] + ); + }); + + expect(notificationsServiceStub.success).toHaveBeenCalled(); + + }); + + it('should show an error notification and call updateSectionData on upload complete', () => { + + const responseErrors = mockUploadResponse2Errors; + + const expectedErrors: any = mockUploadResponse2ParsedErrors; + compAsAny.uploadEnabled = observableOf(true); + fixture.detectChanges(); + + comp.onCompleteItem(Object.assign({}, uploadRestResponse, { + sections: mockSectionsData, + errors: responseErrors.errors + })); + + Object.keys(mockSectionsData).forEach((sectionId) => { + expect(sectionsServiceStub.updateSectionData).toHaveBeenCalledWith( + submissionId, + sectionId, + mockSectionsData[sectionId], + expectedErrors[sectionId] + ); + }); + + expect(notificationsServiceStub.success).not.toHaveBeenCalled(); + + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + submissionId = mockSubmissionId; + collectionId = mockSubmissionCollectionId; + sectionId = 'upload'; + uploadFilesOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + +} diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts new file mode 100644 index 0000000000..be3e6b5c8c --- /dev/null +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.ts @@ -0,0 +1,171 @@ +import { Component, Input, OnChanges } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { SectionsService } from '../../sections/sections.service'; +import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { Workspaceitem } from '../../../core/submission/models/workspaceitem.model'; +import { normalizeSectionData } from '../../../core/submission/submission-response-parsing.service'; +import { SubmissionService } from '../../submission.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { UploaderOptions } from '../../../shared/uploader/uploader-options.model'; +import parseSectionErrors from '../../utils/parseSectionErrors'; +import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; + +/** + * This component represents the drop zone that provides to add files to the submission. + */ +@Component({ + selector: 'ds-submission-upload-files', + templateUrl: './submission-upload-files.component.html', +}) +export class SubmissionUploadFilesComponent implements OnChanges { + + /** + * The collection id this submission belonging to + * @type {string} + */ + @Input() collectionId: string; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * The upload section id + * @type {string} + */ + @Input() sectionId: string; + + /** + * The uploader configuration options + * @type {UploaderOptions} + */ + @Input() uploadFilesOptions: UploaderOptions; + + /** + * A boolean representing if is possible to active drop zone over the document page + * @type {boolean} + */ + public enableDragOverDocument = true; + + /** + * i18n message label + * @type {string} + */ + public dropOverDocumentMsg = 'submission.sections.upload.drop-message'; + + /** + * i18n message label + * @type {string} + */ + public dropMsg = 'submission.sections.upload.drop-message'; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * A boolean representing if upload functionality is enabled + * @type {boolean} + */ + private uploadEnabled: Observable = observableOf(false); + + /** + * Save submission before to upload a file + */ + public onBeforeUpload = () => { + const sub: Subscription = this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + 'sections') + .subscribe(); + this.subs.push(sub); + return sub; + }; + + /** + * Initialize instance variables + * + * @param {NotificationsService} notificationsService + * @param {SubmissionJsonPatchOperationsService} operationsService + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {TranslateService} translate + */ + constructor(private notificationsService: NotificationsService, + private operationsService: SubmissionJsonPatchOperationsService, + private sectionService: SectionsService, + private submissionService: SubmissionService, + private translate: TranslateService) { + } + + /** + * Check if upload functionality is enabled + */ + ngOnChanges() { + this.uploadEnabled = this.sectionService.isSectionAvailable(this.submissionId, this.sectionId); + } + + /** + * Parse the submission object retrieved from REST after upload + * + * @param workspaceitem + * The submission object retrieved from REST + */ + public onCompleteItem(workspaceitem: Workspaceitem) { + // Checks if upload section is enabled so do upload + this.subs.push( + this.uploadEnabled + .pipe(first()) + .subscribe((isUploadEnabled) => { + if (isUploadEnabled) { + + const { sections } = workspaceitem; + const { errors } = workspaceitem; + + const errorsList = parseSectionErrors(errors); + if (sections && isNotEmpty(sections)) { + Object.keys(sections) + .forEach((sectionId) => { + const sectionData = normalizeSectionData(sections[sectionId]); + const sectionErrors = errorsList[sectionId]; + if (sectionId === 'upload') { + // Look for errors on upload + if ((isEmpty(sectionErrors))) { + this.notificationsService.success(null, this.translate.get('submission.sections.upload.upload-successful')); + } else { + this.notificationsService.error(null, this.translate.get('submission.sections.upload.upload-failed')); + } + } + this.sectionService.updateSectionData(this.submissionId, sectionId, sectionData, sectionErrors) + }) + } + + } + }) + ); + } + + /** + * Show error notification on upload fails + */ + public onUploadError() { + this.notificationsService.error(null, this.translate.get('submission.sections.upload.upload-failed')); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts new file mode 100644 index 0000000000..9bd88f035a --- /dev/null +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -0,0 +1,799 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; +import { SectionVisibility, SubmissionSectionError } from './submission-objects.reducer'; +import { WorkspaceitemSectionUploadFileObject } from '../../core/submission/models/workspaceitem-section-upload-file.model'; +import { + WorkspaceitemSectionDataType, + WorkspaceitemSectionsObject +} from '../../core/submission/models/workspaceitem-sections.model'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { SectionsType } from '../sections/sections-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 SubmissionObjectActionTypes = { + // Section types + INIT_SUBMISSION_FORM: type('dspace/submission/INIT_SUBMISSION_FORM'), + RESET_SUBMISSION_FORM: type('dspace/submission/RESET_SUBMISSION_FORM'), + CANCEL_SUBMISSION_FORM: type('dspace/submission/CANCEL_SUBMISSION_FORM'), + COMPLETE_INIT_SUBMISSION_FORM: type('dspace/submission/COMPLETE_INIT_SUBMISSION_FORM'), + SAVE_FOR_LATER_SUBMISSION_FORM: type('dspace/submission/SAVE_FOR_LATER_SUBMISSION_FORM'), + SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS: type('dspace/submission/SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS'), + SAVE_FOR_LATER_SUBMISSION_FORM_ERROR: type('dspace/submission/SAVE_FOR_LATER_SUBMISSION_FORM_ERROR'), + SAVE_SUBMISSION_FORM: type('dspace/submission/SAVE_SUBMISSION_FORM'), + SAVE_SUBMISSION_FORM_SUCCESS: type('dspace/submission/SAVE_SUBMISSION_FORM_SUCCESS'), + SAVE_SUBMISSION_FORM_ERROR: type('dspace/submission/SAVE_SUBMISSION_FORM_ERROR'), + SAVE_SUBMISSION_SECTION_FORM: type('dspace/submission/SAVE_SUBMISSION_SECTION_FORM'), + SAVE_SUBMISSION_SECTION_FORM_SUCCESS: type('dspace/submission/SAVE_SUBMISSION_SECTION_FORM_SUCCESS'), + SAVE_SUBMISSION_SECTION_FORM_ERROR: type('dspace/submission/SAVE_SUBMISSION_SECTION_FORM_ERROR'), + CHANGE_SUBMISSION_COLLECTION: type('dspace/submission/CHANGE_SUBMISSION_COLLECTION'), + SET_ACTIVE_SECTION: type('dspace/submission/SET_ACTIVE_SECTION'), + INIT_SECTION: type('dspace/submission/INIT_SECTION'), + ENABLE_SECTION: type('dspace/submission/ENABLE_SECTION'), + DISABLE_SECTION: type('dspace/submission/DISABLE_SECTION'), + SECTION_STATUS_CHANGE: type('dspace/submission/SECTION_STATUS_CHANGE'), + SECTION_LOADING_STATUS_CHANGE: type('dspace/submission/SECTION_LOADING_STATUS_CHANGE'), + UPLOAD_SECTION_DATA: type('dspace/submission/UPLOAD_SECTION_DATA'), + SAVE_AND_DEPOSIT_SUBMISSION: type('dspace/submission/SAVE_AND_DEPOSIT_SUBMISSION'), + DEPOSIT_SUBMISSION: type('dspace/submission/DEPOSIT_SUBMISSION'), + DEPOSIT_SUBMISSION_SUCCESS: type('dspace/submission/DEPOSIT_SUBMISSION_SUCCESS'), + DEPOSIT_SUBMISSION_ERROR: type('dspace/submission/DEPOSIT_SUBMISSION_ERROR'), + DISCARD_SUBMISSION: type('dspace/submission/DISCARD_SUBMISSION'), + DISCARD_SUBMISSION_SUCCESS: type('dspace/submission/DISCARD_SUBMISSION_SUCCESS'), + DISCARD_SUBMISSION_ERROR: type('dspace/submission/DISCARD_SUBMISSION_ERROR'), + + // Upload file types + NEW_FILE: type('dspace/submission/NEW_FILE'), + EDIT_FILE_DATA: type('dspace/submission/EDIT_FILE_DATA'), + DELETE_FILE: type('dspace/submission/DELETE_FILE'), + + // Errors + ADD_SECTION_ERROR: type('dspace/submission/ADD_SECTION_ERROR'), + DELETE_SECTION_ERROR: type('dspace/submission/DELETE_SECTION_ERROR'), + REMOVE_SECTION_ERRORS: type('dspace/submission/REMOVE_SECTION_ERRORS'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * Insert a new error of type SubmissionSectionError into the given section + * @param {string} submissionId + * @param {string} sectionId + * @param {SubmissionSectionError} error + */ +export class InertSectionErrorsAction implements Action { + type: string = SubmissionObjectActionTypes.ADD_SECTION_ERROR; + payload: { + submissionId: string; + sectionId: string; + error: SubmissionSectionError | SubmissionSectionError[]; + }; + + constructor(submissionId: string, sectionId: string, error: SubmissionSectionError | SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, error }; + } +} + +/** + * Delete a SubmissionSectionError from the given section + * @param {string} submissionId + * @param {string} sectionId + * @param {string | SubmissionSectionError} error + */ +export class DeleteSectionErrorsAction implements Action { + type: string = SubmissionObjectActionTypes.DELETE_SECTION_ERROR; + payload: { + submissionId: string; + sectionId: string; + errors: SubmissionSectionError | SubmissionSectionError[]; + }; + + constructor(submissionId: string, sectionId: string, errors: SubmissionSectionError | SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, errors }; + } +} + +// Section actions + +export class InitSectionAction implements Action { + type = SubmissionObjectActionTypes.INIT_SECTION; + payload: { + submissionId: string; + sectionId: string; + header: string; + config: string; + mandatory: boolean; + sectionType: SectionsType; + visibility: SectionVisibility; + enabled: boolean; + data: WorkspaceitemSectionDataType; + errors: SubmissionSectionError[]; + }; + + /** + * Create a new InitSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + * @param header + * the section's header + * @param config + * the section's config + * @param mandatory + * the section's mandatory + * @param sectionType + * the section's type + * @param visibility + * the section's visibility + * @param enabled + * the section's enabled state + * @param data + * the section's data + * @param errors + * the section's errors + */ + constructor(submissionId: string, + sectionId: string, + header: string, + config: string, + mandatory: boolean, + sectionType: SectionsType, + visibility: SectionVisibility, + enabled: boolean, + data: WorkspaceitemSectionDataType, + errors: SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, header, config, mandatory, sectionType, visibility, enabled, data, errors }; + } +} + +export class EnableSectionAction implements Action { + type = SubmissionObjectActionTypes.ENABLE_SECTION; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new EnableSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + */ + constructor(submissionId: string, + sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class DisableSectionAction implements Action { + type = SubmissionObjectActionTypes.DISABLE_SECTION; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new DisableSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to remove + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class UpdateSectionDataAction implements Action { + type = SubmissionObjectActionTypes.UPLOAD_SECTION_DATA; + payload: { + submissionId: string; + sectionId: string; + data: WorkspaceitemSectionDataType; + errors: SubmissionSectionError[]; + }; + + /** + * Create a new EnableSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + * @param data + * the section's data + * @param errors + * the section's errors + */ + constructor(submissionId: string, + sectionId: string, + data: WorkspaceitemSectionDataType, + errors: SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, data, errors }; + } +} + +export class RemoveSectionErrorsAction implements Action { + type = SubmissionObjectActionTypes.REMOVE_SECTION_ERRORS; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new RemoveSectionErrorsAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +// Submission actions + +export class CompleteInitSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.COMPLETE_INIT_SUBMISSION_FORM; + payload: { + submissionId: string; + }; + + /** + * Create a new CompleteInitSubmissionFormAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class InitSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.INIT_SUBMISSION_FORM; + payload: { + collectionId: string; + submissionId: string; + selfUrl: string; + submissionDefinition: SubmissionDefinitionsModel; + sections: WorkspaceitemSectionsObject; + errors: SubmissionSectionError[]; + }; + + /** + * Create a new InitSubmissionFormAction + * + * @param collectionId + * the collection's Id where to deposit + * @param submissionId + * the submission's ID + * @param selfUrl + * the submission object url + * @param submissionDefinition + * the submission's sections definition + * @param sections + * the submission's sections + * @param errors + * the submission's sections errors + */ + constructor(collectionId: string, + submissionId: string, + selfUrl: string, + submissionDefinition: SubmissionDefinitionsModel, + sections: WorkspaceitemSectionsObject, + errors: SubmissionSectionError[]) { + this.payload = { collectionId, submissionId, selfUrl, submissionDefinition, sections, errors }; + } +} + +export class SaveForLaterSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveForLaterSubmissionFormAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveForLaterSubmissionFormSuccessAction implements Action { + type = SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS; + payload: { + submissionId: string; + submissionObject: SubmissionObject[]; + }; + + /** + * Create a new SaveForLaterSubmissionFormSuccessAction + * + * @param submissionId + * the submission's ID + * @param submissionObject + * the submission's Object + */ + constructor(submissionId: string, submissionObject: SubmissionObject[]) { + this.payload = { submissionId, submissionObject }; + } +} + +export class SaveForLaterSubmissionFormErrorAction implements Action { + type = SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveForLaterSubmissionFormErrorAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveSubmissionFormAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveSubmissionFormSuccessAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS; + payload: { + submissionId: string; + submissionObject: SubmissionObject[]; + }; + + /** + * Create a new SaveSubmissionFormSuccessAction + * + * @param submissionId + * the submission's ID + * @param submissionObject + * the submission's Object + */ + constructor(submissionId: string, submissionObject: SubmissionObject[]) { + this.payload = { submissionId, submissionObject }; + } +} + +export class SaveSubmissionFormErrorAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveSubmissionFormErrorAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SaveSubmissionSectionFormAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new SaveSubmissionSectionFormAction + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class SaveSubmissionSectionFormSuccessAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS; + payload: { + submissionId: string; + submissionObject: SubmissionObject[]; + }; + + /** + * Create a new SaveSubmissionSectionFormSuccessAction + * + * @param submissionId + * the submission's ID + * @param submissionObject + * the submission's Object + */ + constructor(submissionId: string, submissionObject: SubmissionObject[]) { + this.payload = { submissionId, submissionObject }; + } +} + +export class SaveSubmissionSectionFormErrorAction implements Action { + type = SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveSubmissionFormErrorAction + * + * @param submissionId + * the submission's ID + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class ResetSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.RESET_SUBMISSION_FORM; + payload: { + collectionId: string; + submissionId: string; + selfUrl: string; + sections: WorkspaceitemSectionsObject; + submissionDefinition: SubmissionDefinitionsModel; + }; + + /** + * Create a new ResetSubmissionFormAction + * + * @param collectionId + * the collection's Id where to deposit + * @param submissionId + * the submission's ID + * @param selfUrl + * the submission object url + * @param sections + * the submission's sections + * @param submissionDefinition + * the submission's form definition + */ + constructor(collectionId: string, submissionId: string, selfUrl: string, sections: WorkspaceitemSectionsObject, submissionDefinition: SubmissionDefinitionsModel) { + this.payload = { collectionId, submissionId, selfUrl, sections, submissionDefinition }; + } +} + +export class CancelSubmissionFormAction implements Action { + type = SubmissionObjectActionTypes.CANCEL_SUBMISSION_FORM; +} + +export class ChangeSubmissionCollectionAction implements Action { + type = SubmissionObjectActionTypes.CHANGE_SUBMISSION_COLLECTION; + payload: { + submissionId: string; + collectionId: string; + }; + + /** + * Create a new ChangeSubmissionCollectionAction + * + * @param submissionId + * the submission's ID + * @param collectionId + * the new collection's ID + */ + constructor(submissionId: string, collectionId: string) { + this.payload = { submissionId, collectionId }; + } +} + +export class SaveAndDepositSubmissionAction implements Action { + type = SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION; + payload: { + submissionId: string; + }; + + /** + * Create a new SaveAndDepositSubmissionAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DepositSubmissionAction implements Action { + type = SubmissionObjectActionTypes.DEPOSIT_SUBMISSION; + payload: { + submissionId: string; + }; + + /** + * Create a new DepositSubmissionAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DepositSubmissionSuccessAction implements Action { + type = SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS; + payload: { + submissionId: string; + }; + + /** + * Create a new DepositSubmissionSuccessAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DepositSubmissionErrorAction implements Action { + type = SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new DepositSubmissionErrorAction + * + * @param submissionId + * the submission's ID to deposit + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DiscardSubmissionAction implements Action { + type = SubmissionObjectActionTypes.DISCARD_SUBMISSION; + payload: { + submissionId: string; + }; + + /** + * Create a new DiscardSubmissionAction + * + * @param submissionId + * the submission's ID to discard + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DiscardSubmissionSuccessAction implements Action { + type = SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS; + payload: { + submissionId: string; + }; + + /** + * Create a new DiscardSubmissionSuccessAction + * + * @param submissionId + * the submission's ID to discard + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class DiscardSubmissionErrorAction implements Action { + type = SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR; + payload: { + submissionId: string; + }; + + /** + * Create a new DiscardSubmissionErrorAction + * + * @param submissionId + * the submission's ID to discard + */ + constructor(submissionId: string) { + this.payload = { submissionId }; + } +} + +export class SectionStatusChangeAction implements Action { + type = SubmissionObjectActionTypes.SECTION_STATUS_CHANGE; + payload: { + submissionId: string; + sectionId: string; + status: boolean + }; + + /** + * Change the section validity status + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID to change + * @param status + * the section validity status (true if is valid) + */ + constructor(submissionId: string, sectionId: string, status: boolean) { + this.payload = { submissionId, sectionId, status }; + } +} + +export class SetActiveSectionAction implements Action { + type = SubmissionObjectActionTypes.SET_ACTIVE_SECTION; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new SetActiveSectionAction + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID to active + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} +// Upload file actions + +export class NewUploadedFileAction implements Action { + type = SubmissionObjectActionTypes.NEW_FILE; + payload: { + submissionId: string; + sectionId: string; + fileId: string; + data: WorkspaceitemSectionUploadFileObject; + }; + + /** + * Add a new uploaded file + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + * @param fileId + * the file's ID + * @param data + * the metadata of the new bitstream + */ + constructor(submissionId: string, sectionId: string, fileId: string, data: WorkspaceitemSectionUploadFileObject) { + this.payload = { submissionId, sectionId, fileId, data }; + } +} + +export class EditFileDataAction implements Action { + type = SubmissionObjectActionTypes.EDIT_FILE_DATA; + payload: { + submissionId: string; + sectionId: string; + fileId: string; + data: WorkspaceitemSectionUploadFileObject; + }; + + /** + * Edit a file data + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + * @param fileId + * the file's ID + * @param data + * the metadata of the new bitstream + */ + constructor(submissionId: string, sectionId: string, fileId: string, data: WorkspaceitemSectionUploadFileObject) { + this.payload = { submissionId, sectionId, fileId: fileId, data }; + } +} + +export class DeleteUploadedFileAction implements Action { + type = SubmissionObjectActionTypes.DELETE_FILE; + payload: { + submissionId: string; + sectionId: string; + fileId: string; + }; + + /** + * Delete a uploaded file + * + * @param submissionId + * the submission's ID + * @param sectionId + * the section's ID + * @param fileId + * the file's ID + */ + constructor(submissionId: string, sectionId: string, fileId: string) { + this.payload = { submissionId, sectionId, fileId }; + } +} + +/* 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 SubmissionObjectAction = DisableSectionAction + | InitSectionAction + | EnableSectionAction + | InitSubmissionFormAction + | ResetSubmissionFormAction + | CancelSubmissionFormAction + | CompleteInitSubmissionFormAction + | ChangeSubmissionCollectionAction + | SaveAndDepositSubmissionAction + | DepositSubmissionAction + | DepositSubmissionSuccessAction + | DepositSubmissionErrorAction + | DiscardSubmissionAction + | DiscardSubmissionSuccessAction + | DiscardSubmissionErrorAction + | SectionStatusChangeAction + | NewUploadedFileAction + | EditFileDataAction + | DeleteUploadedFileAction + | InertSectionErrorsAction + | DeleteSectionErrorsAction + | UpdateSectionDataAction + | RemoveSectionErrorsAction + | SaveForLaterSubmissionFormAction + | SaveForLaterSubmissionFormSuccessAction + | SaveForLaterSubmissionFormErrorAction + | SaveSubmissionFormAction + | SaveSubmissionFormSuccessAction + | SaveSubmissionFormErrorAction + | SaveSubmissionSectionFormAction + | SaveSubmissionSectionFormSuccessAction + | SaveSubmissionSectionFormErrorAction + | SetActiveSectionAction; diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts new file mode 100644 index 0000000000..8bbdd4e0ee --- /dev/null +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -0,0 +1,840 @@ +import { TestBed } from '@angular/core/testing'; + +import { cold, hot } from 'jasmine-marbles'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { SubmissionObjectEffects } from './submission-objects.effects'; +import { + CompleteInitSubmissionFormAction, + DepositSubmissionAction, + DepositSubmissionErrorAction, + DepositSubmissionSuccessAction, DiscardSubmissionErrorAction, DiscardSubmissionSuccessAction, + InitSectionAction, + InitSubmissionFormAction, + SaveForLaterSubmissionFormSuccessAction, + SaveSubmissionFormErrorAction, + SaveSubmissionFormSuccessAction, + SaveSubmissionSectionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, + SubmissionObjectActionTypes, + UpdateSectionDataAction +} from './submission-objects.actions'; +import { + mockSectionsData, + mockSectionsDataTwo, + mockSectionsErrors, + mockSubmissionCollectionId, + mockSubmissionDefinition, + mockSubmissionDefinitionResponse, + mockSubmissionId, + mockSubmissionSelfUrl, + mockSubmissionState, + mockSubmissionRestResponse +} from '../../shared/mocks/mock-submission'; +import { SubmissionSectionModel } from '../../core/config/models/config-submission-section.model'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionJsonPatchOperationsServiceStub } from '../../shared/testing/submission-json-patch-operations-service-stub'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; +import { SectionsService } from '../sections/sections.service'; +import { SectionsServiceStub } from '../../shared/testing/sections-service-stub'; +import { SubmissionService } from '../submission.service'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service-stub'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { MockStore } from '../../shared/testing/mock-store'; +import { AppState } from '../../app.reducer'; +import parseSectionErrors from '../utils/parseSectionErrors'; + +describe('SubmissionObjectEffects test suite', () => { + let submissionObjectEffects: SubmissionObjectEffects; + let actions: Observable; + let store: MockStore; + + const notificationsServiceStub = new NotificationsServiceStub(); + const submissionServiceStub = new SubmissionServiceStub(); + const submissionJsonPatchOperationsServiceStub = new SubmissionJsonPatchOperationsServiceStub(); + const collectionId: string = mockSubmissionCollectionId; + const submissionId: string = mockSubmissionId; + const submissionDefinitionResponse: any = mockSubmissionDefinitionResponse; + const submissionDefinition: any = mockSubmissionDefinition; + const selfUrl: string = mockSubmissionSelfUrl; + const submissionState: any = Object.assign({}, mockSubmissionState); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + providers: [ + SubmissionObjectEffects, + TranslateService, + { provide: Store, useClass: MockStore }, + provideMockActions(() => actions), + { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useValue: submissionServiceStub }, + { provide: SubmissionJsonPatchOperationsService, useValue: submissionJsonPatchOperationsServiceStub }, + ], + }); + + submissionObjectEffects = TestBed.get(SubmissionObjectEffects); + store = TestBed.get(Store); + }); + + describe('loadForm$', () => { + it('should return a INIT_SECTION action for each defined section and a COMPLETE_INIT_SUBMISSION_FORM action', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.INIT_SUBMISSION_FORM, + payload: { + collectionId: collectionId, + submissionId: submissionId, + selfUrl: selfUrl, + submissionDefinition: submissionDefinition, + sections: {}, + errors: [], + } + } + }); + + const mappedActions = []; + (submissionDefinitionResponse.sections as SubmissionSectionModel[]) + .forEach((sectionDefinition: SubmissionSectionModel) => { + const sectionId = sectionDefinition._links.self.substr(sectionDefinition._links.self.lastIndexOf('/') + 1); + const config = sectionDefinition._links.config || ''; + const enabled = (sectionDefinition.mandatory); + const sectionData = {}; + const sectionErrors = null; + mappedActions.push(new InitSectionAction( + submissionId, + sectionId, + sectionDefinition.header, + config, + sectionDefinition.mandatory, + sectionDefinition.sectionType, + sectionDefinition.visibility, + enabled, + sectionData, + sectionErrors)) + }); + mappedActions.push(new CompleteInitSubmissionFormAction(submissionId)); + + const expected = cold('--(bcdefgh)', { + b: mappedActions[0], + c: mappedActions[1], + d: mappedActions[2], + e: mappedActions[3], + f: mappedActions[4], + g: mappedActions[5], + h: mappedActions[6] + }); + + expect(submissionObjectEffects.loadForm$).toBeObservable(expected); + }); + }); + + describe('resetForm$', () => { + it('should return a INIT_SUBMISSION_FORM action', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.RESET_SUBMISSION_FORM, + payload: { + collectionId: collectionId, + submissionId: submissionId, + selfUrl: selfUrl, + submissionDefinition: submissionDefinition, + sections: {}, + errors: [], + } + } + }); + + const expected = cold('--b-', { + b: new InitSubmissionFormAction( + collectionId, + submissionId, + selfUrl, + submissionDefinition, + {}, + null + ) + }); + + expect(submissionObjectEffects.resetForm$).toBeObservable(expected); + }); + }); + + describe('saveSubmission$', () => { + it('should return a SAVE_SUBMISSION_FORM_SUCCESS action on success', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM, + payload: { + submissionId: submissionId + } + } + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse)); + const expected = cold('--b-', { + b: new SaveSubmissionFormSuccessAction( + submissionId, + mockSubmissionRestResponse as any + ) + }); + + expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); + }); + + it('should return a SAVE_SUBMISSION_FORM_ERROR action on error', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM, + payload: { + submissionId: submissionId + } + } + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( + () => observableThrowError('Error') + ); + const expected = cold('--b-', { + b: new SaveSubmissionFormErrorAction( + submissionId + ) + }); + + expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); + }); + }); + + describe('saveForLaterSubmission$', () => { + it('should return a SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS action on success', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM, + payload: { + submissionId: submissionId + } + } + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(mockSubmissionRestResponse)); + const expected = cold('--b-', { + b: new SaveForLaterSubmissionFormSuccessAction( + submissionId, + mockSubmissionRestResponse as any + ) + }); + + expect(submissionObjectEffects.saveForLaterSubmission$).toBeObservable(expected); + }); + + it('should return a SAVE_SUBMISSION_FORM_ERROR action on error', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM, + payload: { + submissionId: submissionId + } + } + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( + () => observableThrowError('Error') + ); + const expected = cold('--b-', { + b: new SaveSubmissionFormErrorAction( + submissionId + ) + }); + + expect(submissionObjectEffects.saveForLaterSubmission$).toBeObservable(expected); + }); + }); + + describe('saveSubmissionSuccess$', () => { + + it('should return a UPLOAD_SECTION_DATA action for each updated section', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + const response = [Object.assign({}, mockSubmissionRestResponse[0], { + sections: mockSectionsData, + errors: mockSectionsErrors + })]; + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, + payload: { + submissionId: submissionId, + submissionObject: response + } + } + }); + + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--(bcd)-', { + b: new UpdateSectionDataAction( + submissionId, + 'traditionalpageone', + mockSectionsData.traditionalpageone as any, + errorsList.traditionalpageone || [] + ), + c: new UpdateSectionDataAction( + submissionId, + 'license', + mockSectionsData.license as any, + errorsList.license || [] + ), + d: new UpdateSectionDataAction( + submissionId, + 'upload', + mockSectionsData.upload as any, + errorsList.upload || [] + ), + }); + + expect(submissionObjectEffects.saveSubmissionSuccess$).toBeObservable(expected); + expect(notificationsServiceStub.success).toHaveBeenCalled(); + + }); + + it('should display a success notification', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + const response = [Object.assign({}, mockSubmissionRestResponse[0], { + sections: mockSectionsData + })]; + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, + payload: { + submissionId: submissionId, + submissionObject: response + } + } + }); + + const expected = cold('--(bcd)-', { + b: new UpdateSectionDataAction( + submissionId, + 'traditionalpageone', + mockSectionsData.traditionalpageone as any, + [] + ), + c: new UpdateSectionDataAction( + submissionId, + 'license', + mockSectionsData.license as any, + [] + ), + d: new UpdateSectionDataAction( + submissionId, + 'upload', + mockSectionsData.upload as any, + [] + ), + }); + + expect(submissionObjectEffects.saveSubmissionSuccess$).toBeObservable(expected); + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + + it('should display a warning notification when there are errors', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + const response = [Object.assign({}, mockSubmissionRestResponse[0], { + sections: mockSectionsData, + errors: mockSectionsErrors + })]; + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, + payload: { + submissionId: submissionId, + submissionObject: response + } + } + }); + + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--(bcd)-', { + b: new UpdateSectionDataAction( + submissionId, + 'traditionalpageone', + mockSectionsData.traditionalpageone as any, + errorsList.traditionalpageone || [] + ), + c: new UpdateSectionDataAction( + submissionId, + 'license', + mockSectionsData.license as any, + errorsList.license || [] + ), + d: new UpdateSectionDataAction( + submissionId, + 'upload', + mockSectionsData.upload as any, + errorsList.upload || [] + ), + }); + + expect(submissionObjectEffects.saveSubmissionSuccess$).toBeObservable(expected); + expect(notificationsServiceStub.warning).toHaveBeenCalled(); + }); + + it('should detect and notify a new section', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + const response = [Object.assign({}, mockSubmissionRestResponse[0], { + sections: mockSectionsDataTwo, + errors: mockSectionsErrors + })]; + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, + payload: { + submissionId: submissionId, + submissionObject: response + } + } + }); + + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--(bcde)-', { + b: new UpdateSectionDataAction( + submissionId, + 'traditionalpageone', + mockSectionsDataTwo.traditionalpageone as any, + errorsList.traditionalpageone || [] + ), + c: new UpdateSectionDataAction( + submissionId, + 'traditionalpagetwo', + mockSectionsDataTwo.traditionalpagetwo as any, + errorsList.traditionalpagetwo || [] + ), + d: new UpdateSectionDataAction( + submissionId, + 'license', + mockSectionsDataTwo.license as any, + errorsList.license || [] + ), + e: new UpdateSectionDataAction( + submissionId, + 'upload', + mockSectionsDataTwo.upload as any, + errorsList.upload || [] + ), + }); + + expect(submissionObjectEffects.saveSubmissionSuccess$).toBeObservable(expected); + expect(submissionServiceStub.notifyNewSection).toHaveBeenCalled(); + }); + + }); + + describe('saveSection$', () => { + it('should return a SAVE_SUBMISSION_SECTION_FORM_SUCCESS action on success', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM, + payload: { + submissionId: submissionId, + sectionId: 'traditionalpageone' + } + } + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceID.and.returnValue(observableOf(mockSubmissionRestResponse)); + const expected = cold('--b-', { + b: new SaveSubmissionSectionFormSuccessAction( + submissionId, + mockSubmissionRestResponse as any + ) + }); + + expect(submissionObjectEffects.saveSection$).toBeObservable(expected); + }); + + it('should return a SAVE_SUBMISSION_SECTION_FORM_ERROR action on error', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM, + payload: { + submissionId: submissionId, + sectionId: 'traditionalpageone' + } + } + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceID.and.callFake( + () => observableThrowError('Error') + ); + const expected = cold('--b-', { + b: new SaveSubmissionSectionFormErrorAction( + submissionId + ) + }); + + expect(submissionObjectEffects.saveSection$).toBeObservable(expected); + }); + }); + + describe('saveAndDepositSection$', () => { + it('should return a DEPOSIT_SUBMISSION action on success', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION, + payload: { + submissionId: submissionId + } + } + }); + + const response = [Object.assign({}, mockSubmissionRestResponse[0], { + sections: mockSectionsDataTwo + })]; + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(response)); + const expected = cold('--b-', { + b: new DepositSubmissionAction( + submissionId + ) + }); + + expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); + }); + + it('should not allow to deposit when there are errors', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION, + payload: { + submissionId: submissionId + } + } + }); + + const response = [Object.assign({}, mockSubmissionRestResponse[0], { + sections: mockSectionsData, + errors: mockSectionsErrors + })]; + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.returnValue(observableOf(response)); + + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--b-', { + b: [ + new UpdateSectionDataAction( + submissionId, + 'traditionalpageone', + mockSectionsData.traditionalpageone as any, + errorsList.traditionalpageone || [] + ), + new UpdateSectionDataAction( + submissionId, + 'license', + mockSectionsData.license as any, + errorsList.license || [] + ), + new UpdateSectionDataAction( + submissionId, + 'upload', + mockSectionsData.upload as any, + errorsList.upload || [] + ) + ] + }); + + expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); + }); + + it('should catch errors and return a SAVE_SUBMISSION_FORM_ERROR', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION, + payload: { + submissionId: submissionId + } + } + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( + () => observableThrowError('Error') + ); + const expected = cold('--b-', { + b: new SaveSubmissionFormErrorAction( + submissionId + ) + }); + + expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); + }); + }); + + describe('depositSubmission$', () => { + it('should return a DEPOSIT_SUBMISSION_SUCCESS action on success', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION, + payload: { + submissionId: submissionId + } + } + }); + + submissionServiceStub.depositSubmission.and.returnValue(observableOf(mockSubmissionRestResponse)); + const expected = cold('--b-', { + b: new DepositSubmissionSuccessAction( + submissionId + ) + }); + + expect(submissionObjectEffects.depositSubmission$).toBeObservable(expected); + }); + + it('should return a DEPOSIT_SUBMISSION_ERROR action on error', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION, + payload: { + submissionId: submissionId + } + } + }); + + submissionServiceStub.depositSubmission.and.callFake( + () => observableThrowError('Error') + ); + const expected = cold('--b-', { + b: new DepositSubmissionErrorAction( + submissionId + ) + }); + + expect(submissionObjectEffects.depositSubmission$).toBeObservable(expected); + }); + }); + + describe('saveForLaterSubmissionSuccess$', () => { + it('should display a new success notification and redirect to mydspace', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS, + payload: { + submissionId: submissionId, + submissionObject: mockSubmissionRestResponse + } + } + }); + + submissionObjectEffects.saveForLaterSubmissionSuccess$.subscribe(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(submissionServiceStub.redirectToMyDSpace).toHaveBeenCalled(); + }); + }); + }); + + describe('depositSubmissionSuccess$', () => { + it('should display a new success notification and redirect to mydspace', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.depositSubmissionSuccess$.subscribe(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(submissionServiceStub.redirectToMyDSpace).toHaveBeenCalled(); + }); + }); + }); + + describe('depositSubmissionError$', () => { + it('should display a new error notification', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.depositSubmissionError$.subscribe(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); + }); + + describe('saveError$', () => { + it('should display a new error notification', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.saveError$.subscribe(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); + + it('should display a new error notification', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.saveError$.subscribe(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); + }); + + describe('discardSubmission$', () => { + it('should return a DISCARD_SUBMISSION_SUCCESS action on success', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DISCARD_SUBMISSION, + payload: { + submissionId: submissionId + } + } + }); + + submissionServiceStub.discardSubmission.and.returnValue(observableOf(mockSubmissionRestResponse)); + const expected = cold('--b-', { + b: new DiscardSubmissionSuccessAction( + submissionId + ) + }); + + expect(submissionObjectEffects.discardSubmission$).toBeObservable(expected); + }); + + it('should return a DISCARD_SUBMISSION_ERROR action on error', () => { + store.nextState({ + submission: { + objects: submissionState + } + } as any); + + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DISCARD_SUBMISSION, + payload: { + submissionId: submissionId + } + } + }); + + submissionServiceStub.discardSubmission.and.callFake( + () => observableThrowError('Error') + ); + const expected = cold('--b-', { + b: new DiscardSubmissionErrorAction( + submissionId + ) + }); + + expect(submissionObjectEffects.discardSubmission$).toBeObservable(expected); + }); + }); + + describe('discardSubmissionSuccess$', () => { + it('should display a new success notification and redirect to mydspace', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.discardSubmissionSuccess$.subscribe(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(submissionServiceStub.redirectToMyDSpace).toHaveBeenCalled(); + }); + }); + }); + + describe('discardSubmissionError$', () => { + it('should display a new error notification', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.discardSubmissionError$.subscribe(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts new file mode 100644 index 0000000000..f5c8887320 --- /dev/null +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -0,0 +1,346 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; + +import { from as observableFrom, of as observableOf } from 'rxjs'; +import { catchError, map, mergeMap, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { union } from 'lodash'; + +import { + CompleteInitSubmissionFormAction, + DepositSubmissionAction, + DepositSubmissionErrorAction, + DepositSubmissionSuccessAction, + DiscardSubmissionErrorAction, + DiscardSubmissionSuccessAction, + InitSectionAction, + InitSubmissionFormAction, + ResetSubmissionFormAction, + SaveAndDepositSubmissionAction, + SaveForLaterSubmissionFormAction, + SaveForLaterSubmissionFormSuccessAction, + SaveSubmissionFormAction, + SaveSubmissionFormErrorAction, + SaveSubmissionFormSuccessAction, + SaveSubmissionSectionFormAction, + SaveSubmissionSectionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, SubmissionObjectAction, + SubmissionObjectActionTypes, + UpdateSectionDataAction +} from './submission-objects.actions'; +import { SectionsService } from '../sections/sections.service'; +import { isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { Workspaceitem } from '../../core/submission/models/workspaceitem.model'; +import { SubmissionService } from '../submission.service'; +import { Workflowitem } from '../../core/submission/models/workflowitem.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { TranslateService } from '@ngx-translate/core'; +import { SubmissionState } from '../submission.reducers'; +import { SubmissionObjectEntry } from './submission-objects.reducer'; +import { SubmissionSectionModel } from '../../core/config/models/config-submission-section.model'; +import parseSectionErrors from '../utils/parseSectionErrors'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; +import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; +import { SectionsType } from '../sections/sections-type'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; + +@Injectable() +export class SubmissionObjectEffects { + + /** + * Dispatch a [InitSectionAction] for every submission sections and dispatch a [CompleteInitSubmissionFormAction] + */ + @Effect() loadForm$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.INIT_SUBMISSION_FORM), + map((action: InitSubmissionFormAction) => { + const definition = action.payload.submissionDefinition; + const mappedActions = []; + definition.sections.page.forEach((sectionDefinition: SubmissionSectionModel) => { + const sectionId = sectionDefinition._links.self.substr(sectionDefinition._links.self.lastIndexOf('/') + 1); + const config = sectionDefinition._links.config || ''; + const enabled = (sectionDefinition.mandatory) || (isNotEmpty(action.payload.sections) && action.payload.sections.hasOwnProperty(sectionId)); + const sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); + const sectionErrors = null; + mappedActions.push( + new InitSectionAction( + action.payload.submissionId, + sectionId, + sectionDefinition.header, + config, + sectionDefinition.mandatory, + sectionDefinition.sectionType, + sectionDefinition.visibility, + enabled, + sectionData, + sectionErrors + ) + ) + }); + return {action: action, definition: definition, mappedActions: mappedActions}; + }), + mergeMap((result) => { + return observableFrom( + result.mappedActions.concat( + new CompleteInitSubmissionFormAction(result.action.payload.submissionId) + )); + })); + + /** + * Dispatch a [InitSubmissionFormAction] + */ + @Effect() resetForm$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.RESET_SUBMISSION_FORM), + map((action: ResetSubmissionFormAction) => + new InitSubmissionFormAction( + action.payload.collectionId, + action.payload.submissionId, + action.payload.selfUrl, + action.payload.submissionDefinition, + action.payload.sections, + null + ))); + + /** + * Dispatch a [SaveSubmissionFormSuccessAction] or a [SaveSubmissionFormErrorAction] on error + */ + @Effect() saveSubmission$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM), + switchMap((action: SaveSubmissionFormAction) => { + return this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections').pipe( + map((response: SubmissionObject[]) => new SaveSubmissionFormSuccessAction(action.payload.submissionId, response)), + catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + })); + + /** + * Dispatch a [SaveForLaterSubmissionFormSuccessAction] or a [SaveSubmissionFormErrorAction] on error + */ + @Effect() saveForLaterSubmission$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM), + switchMap((action: SaveForLaterSubmissionFormAction) => { + return this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections').pipe( + map((response: SubmissionObject[]) => new SaveForLaterSubmissionFormSuccessAction(action.payload.submissionId, response)), + catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + })); + + /** + * Call parseSaveResponse and dispatch actions + */ + @Effect() saveSubmissionSuccess$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS), + withLatestFrom(this.store$), + map(([action, currentState]: [SaveSubmissionFormSuccessAction | SaveSubmissionSectionFormSuccessAction, any]) => { + return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId], action.payload.submissionObject, action.payload.submissionId); + }), + mergeMap((actions) => observableFrom(actions))); + + /** + * Dispatch a [SaveSubmissionSectionFormSuccessAction] or a [SaveSubmissionSectionFormErrorAction] on error + */ + @Effect() saveSection$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM), + switchMap((action: SaveSubmissionSectionFormAction) => { + return this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections', + action.payload.sectionId).pipe( + map((response: SubmissionObject[]) => new SaveSubmissionSectionFormSuccessAction(action.payload.submissionId, response)), + catchError(() => observableOf(new SaveSubmissionSectionFormErrorAction(action.payload.submissionId)))); + })); + + /** + * Show a notification on error + */ + @Effect({dispatch: false}) saveError$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR, SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR), + withLatestFrom(this.store$), + tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.save_error_notice')))); + + /** + * Call parseSaveResponse and dispatch actions or dispatch [SaveSubmissionFormErrorAction] on error + */ + @Effect() saveAndDeposit$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION), + withLatestFrom(this.store$), + switchMap(([action, currentState]: [SaveAndDepositSubmissionAction, any]) => { + return this.operationsService.jsonPatchByResourceType( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections').pipe( + map((response: SubmissionObject[]) => { + if (this.canDeposit(response)) { + return new DepositSubmissionAction(action.payload.submissionId); + } else { + this.notificationsService.warning(null, this.translate.get('submission.sections.general.sections_not_valid')); + return this.parseSaveResponse((currentState.submission as SubmissionState).objects[action.payload.submissionId], response, action.payload.submissionId); + } + }), + catchError(() => observableOf(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + })); + + /** + * Dispatch a [DepositSubmissionSuccessAction] or a [DepositSubmissionErrorAction] on error + */ + @Effect() depositSubmission$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION), + withLatestFrom(this.store$), + switchMap(([action, state]: [DepositSubmissionAction, any]) => { + return this.submissionService.depositSubmission(state.submission.objects[action.payload.submissionId].selfUrl).pipe( + map(() => new DepositSubmissionSuccessAction(action.payload.submissionId)), + catchError(() => observableOf(new DepositSubmissionErrorAction(action.payload.submissionId)))); + })); + + /** + * Show a notification on success and redirect to MyDSpace page + */ + @Effect({dispatch: false}) saveForLaterSubmissionSuccess$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS), + tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.save_success_notice'))), + tap(() => this.submissionService.redirectToMyDSpace())); + + /** + * Show a notification on success and redirect to MyDSpace page + */ + @Effect({dispatch: false}) depositSubmissionSuccess$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS), + tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.deposit_success_notice'))), + tap(() => this.submissionService.redirectToMyDSpace())); + + /** + * Show a notification on error + */ + @Effect({dispatch: false}) depositSubmissionError$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR), + tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.deposit_error_notice')))); + + /** + * Dispatch a [DiscardSubmissionSuccessAction] or a [DiscardSubmissionErrorAction] on error + */ + @Effect() discardSubmission$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION), + switchMap((action: DepositSubmissionAction) => { + return this.submissionService.discardSubmission(action.payload.submissionId).pipe( + map(() => new DiscardSubmissionSuccessAction(action.payload.submissionId)), + catchError(() => observableOf(new DiscardSubmissionErrorAction(action.payload.submissionId)))); + })); + + /** + * Show a notification on success and redirect to MyDSpace page + */ + @Effect({dispatch: false}) discardSubmissionSuccess$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS), + tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.discard_success_notice'))), + tap(() => this.submissionService.redirectToMyDSpace())); + + /** + * Show a notification on error + */ + @Effect({dispatch: false}) discardSubmissionError$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR), + tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.discard_error_notice')))); + + constructor(private actions$: Actions, + private notificationsService: NotificationsService, + private operationsService: SubmissionJsonPatchOperationsService, + private sectionService: SectionsService, + private store$: Store, + private submissionService: SubmissionService, + private translate: TranslateService) { + } + + /** + * Check if the submission object retrieved from REST haven't section errors + * + * @param response + * The submission object retrieved from REST + */ + protected canDeposit(response: SubmissionObject[]) { + let canDeposit = true; + + if (isNotEmpty(response)) { + response.forEach((item: Workspaceitem | Workflowitem) => { + const {errors} = item; + + if (errors && !isEmpty(errors)) { + canDeposit = false; + } + }); + } + return canDeposit; + } + + /** + * Parse the submission object retrieved from REST and return actions to dispatch + * + * @param currentState + * The current SubmissionObjectEntry + * @param response + * The submission object retrieved from REST + * @param submissionId + * The submission id + * @param notify + * A boolean that indicate if show notification or not + * @return SubmissionObjectAction[] + * List of SubmissionObjectAction to dispatch + */ + protected parseSaveResponse( + currentState: SubmissionObjectEntry, + response: SubmissionObject[], + submissionId: string, + notify: boolean = true): SubmissionObjectAction[] { + + const mappedActions = []; + + if (isNotEmpty(response)) { + if (notify) { + this.notificationsService.success(null, this.translate.get('submission.sections.general.save_success_notice')); + } + + response.forEach((item: Workspaceitem | Workflowitem) => { + + let errorsList = Object.create({}); + const {errors} = item; + + if (errors && !isEmpty(errors)) { + // to avoid dispatching an action for every error, create an array of errors per section + errorsList = parseSectionErrors(errors); + if (notify) { + this.notificationsService.warning(null, this.translate.get('submission.sections.general.sections_not_valid')); + } + } + + const sections: WorkspaceitemSectionsObject = (item.sections && isNotEmpty(item.sections)) ? item.sections : {}; + const sectionsKeys: string[] = union(Object.keys(sections), Object.keys(errorsList)); + + for (const sectionId of sectionsKeys) { + const sectionErrors = errorsList[sectionId] || []; + const sectionData = sections[sectionId] || {}; + + // When Upload section is disabled, add to submission only if there are files + if (currentState.sections[sectionId].sectionType === SectionsType.Upload + && isEmpty((sectionData as WorkspaceitemSectionUploadObject).files) + && !currentState.sections[sectionId].enabled) { + continue; + } + + if (notify && !currentState.sections[sectionId].enabled) { + this.submissionService.notifyNewSection(submissionId, sectionId, currentState.sections[sectionId].sectionType); + } + mappedActions.push(new UpdateSectionDataAction(submissionId, sectionId, sectionData, sectionErrors)); + } + + }); + + } + + return mappedActions; + } + +} diff --git a/src/app/submission/objects/submission-objects.reducer.spec.ts b/src/app/submission/objects/submission-objects.reducer.spec.ts new file mode 100644 index 0000000000..a5e0be451b --- /dev/null +++ b/src/app/submission/objects/submission-objects.reducer.spec.ts @@ -0,0 +1,634 @@ +import { submissionObjectReducer, SubmissionObjectState } from './submission-objects.reducer'; +import { + CancelSubmissionFormAction, + ChangeSubmissionCollectionAction, + CompleteInitSubmissionFormAction, + DeleteSectionErrorsAction, + DeleteUploadedFileAction, + DepositSubmissionAction, + DepositSubmissionErrorAction, + DepositSubmissionSuccessAction, + DisableSectionAction, + DiscardSubmissionAction, + DiscardSubmissionSuccessAction, + EditFileDataAction, + EnableSectionAction, + InertSectionErrorsAction, + InitSectionAction, + InitSubmissionFormAction, + NewUploadedFileAction, + RemoveSectionErrorsAction, + ResetSubmissionFormAction, + SaveAndDepositSubmissionAction, + SaveForLaterSubmissionFormAction, + SaveForLaterSubmissionFormErrorAction, + SaveForLaterSubmissionFormSuccessAction, + SaveSubmissionFormAction, + SaveSubmissionFormErrorAction, + SaveSubmissionFormSuccessAction, + SaveSubmissionSectionFormAction, + SaveSubmissionSectionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, + SectionStatusChangeAction, + UpdateSectionDataAction +} from './submission-objects.actions'; +import { SectionsType } from '../sections/sections-type'; +import { + mockSubmissionCollectionId, + mockSubmissionDefinitionResponse, + mockSubmissionId, + mockSubmissionSelfUrl, + mockSubmissionState +} from '../../shared/mocks/mock-submission'; + +describe('submissionReducer test suite', () => { + + const collectionId = mockSubmissionCollectionId; + const submissionId = mockSubmissionId; + const submissionDefinition = mockSubmissionDefinitionResponse; + const selfUrl = mockSubmissionSelfUrl; + + let initState: any; + + beforeEach(() => { + initState = Object.assign({}, {}, mockSubmissionState); + }); + + it('should init submission state properly', () => { + const expectedState = { + 826: { + collection: collectionId, + definition: 'traditional', + selfUrl: selfUrl, + activeSection: null, + sections: Object.create(null), + isLoading: true, + savePending: false, + depositPending: false, + } + }; + + const action = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, []); + const newState = submissionObjectReducer({}, action); + + expect(newState).toEqual(expectedState); + }); + + it('should complete submission initialization', () => { + const state = Object.assign({}, initState, { + [submissionId]: Object.assign({}, initState[submissionId], { + isLoading: true + }) + }); + + const action = new CompleteInitSubmissionFormAction(submissionId); + const newState = submissionObjectReducer(state, action); + + expect(newState).toEqual(initState); + }); + + it('should reset submission state properly', () => { + const expectedState = { + 826: { + collection: collectionId, + definition: 'traditional', + selfUrl: selfUrl, + activeSection: null, + sections: Object.create(null), + isLoading: true, + savePending: false, + depositPending: false, + } + }; + + const action = new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, {}, submissionDefinition); + const newState = submissionObjectReducer(initState, action); + + expect(newState).toEqual(expectedState); + }); + + it('should cancel submission state properly', () => { + const expectedState = Object.create({}); + + const action = new CancelSubmissionFormAction(); + const newState = submissionObjectReducer(initState, action); + + expect(newState).toEqual(expectedState); + }); + + it('should set to true savePendig flag on save', () => { + let action = new SaveSubmissionFormAction(submissionId); + let newState = submissionObjectReducer(initState, action); + + expect(newState[826].savePending).toBeTruthy(); + + action = new SaveForLaterSubmissionFormAction(submissionId); + newState = submissionObjectReducer(initState, action); + + expect(newState[826].savePending).toBeTruthy(); + + action = new SaveAndDepositSubmissionAction(submissionId); + newState = submissionObjectReducer(initState, action); + + expect(newState[826].savePending).toBeTruthy(); + + action = new SaveSubmissionSectionFormAction(submissionId, 'traditionalpageone'); + newState = submissionObjectReducer(initState, action); + + expect(newState[826].savePending).toBeTruthy(); + }); + + it('should set to false savePendig flag once the save is completed', () => { + const state = Object.assign({}, initState, { + [submissionId]: Object.assign({}, initState[submissionId], { + savePending: true, + }) + }); + + let action: any = new SaveSubmissionFormSuccessAction(submissionId, []); + let newState = submissionObjectReducer(state, action); + + expect(newState[826].savePending).toBeFalsy(); + + action = new SaveSubmissionSectionFormSuccessAction(submissionId, []); + newState = submissionObjectReducer(state, action); + + expect(newState[826].savePending).toBeFalsy(); + + action = new SaveSubmissionFormErrorAction(submissionId); + newState = submissionObjectReducer(state, action); + + expect(newState[826].savePending).toBeFalsy(); + + action = new SaveForLaterSubmissionFormErrorAction(submissionId); + newState = submissionObjectReducer(state, action); + + expect(newState[826].savePending).toBeFalsy(); + + action = new SaveSubmissionSectionFormErrorAction(submissionId); + newState = submissionObjectReducer(state, action); + + expect(newState[826].savePending).toBeFalsy(); + }); + + it('should change submission collection state properly', () => { + const newCollection = '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f'; + const action = new ChangeSubmissionCollectionAction('826', newCollection); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].collection).toEqual(newCollection); + }); + + it('should set to true depositPending flag on deposit', () => { + const action = new DepositSubmissionAction('826'); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].depositPending).toBeTruthy(); + }); + + it('should reset state once the deposit is completed successfully', () => { + const state = Object.assign({}, initState, { + [submissionId]: Object.assign({}, initState[submissionId], { + depositPending: true, + }) + }); + + const action: any = new DepositSubmissionSuccessAction(submissionId); + const newState = submissionObjectReducer(state, action); + + expect(newState).toEqual({}); + }); + + it('should set to false depositPending flag once the deposit is completed unsuccessfully', () => { + const action = new DepositSubmissionErrorAction('826'); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].depositPending).toBeFalsy(); + }); + + it('should return same state on discard', () => { + const action: any = new DiscardSubmissionAction(submissionId); + const newState = submissionObjectReducer(initState, action); + + expect(newState).toEqual(initState); + }); + + it('should reset state once the discard action is completed successfully', () => { + const action: any = new DiscardSubmissionSuccessAction(submissionId); + const newState = submissionObjectReducer(initState, action); + + expect(newState).toEqual({}); + }); + + it('should return same state once the discard action is completed unsuccessfully', () => { + const action: any = new DiscardSubmissionAction(submissionId); + const newState = submissionObjectReducer(initState, action); + + expect(newState).toEqual(initState); + }); + + it('should init submission section state properly', () => { + const expectedState = { + header: 'submit.progressbar.describe.stepone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', + mandatory: true, + sectionType: 'submission-form', + visibility: undefined, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } as any; + + let action: any = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, []); + let newState = submissionObjectReducer({}, action); + + action = new InitSectionAction( + submissionId, + 'traditionalpageone', + 'submit.progressbar.describe.stepone', + 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', + true, + SectionsType.SubmissionForm, + undefined, + true, + {}, + null); + + newState = submissionObjectReducer(newState, action); + + expect(newState[826].sections.traditionalpageone).toEqual(expectedState); + }); + + it('should enable submission section properly', () => { + + const action = new EnableSectionAction(submissionId, 'traditionalpagetwo'); + + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.traditionalpagetwo.enabled).toBeTruthy(); + }); + + it('should enable submission section properly', () => { + + let action = new EnableSectionAction(submissionId, 'traditionalpagetwo'); + let newState = submissionObjectReducer(initState, action); + + action = new DisableSectionAction(submissionId, 'traditionalpagetwo'); + newState = submissionObjectReducer(newState, action); + + expect(newState[826].sections.traditionalpagetwo.enabled).toBeFalsy(); + }); + + it('should set to true/false submission section status', () => { + + let action = new SectionStatusChangeAction(submissionId, 'traditionalpageone', true); + let newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.traditionalpageone.isValid).toBeTruthy(); + + action = new SectionStatusChangeAction(submissionId, 'traditionalpageone', false); + newState = submissionObjectReducer(newState, action); + + expect(newState[826].sections.traditionalpageone.isValid).toBeFalsy(); + }); + + it('should update submission section data properly', () => { + const data = { + 'dc.contributor.author': [ + { + value: 'Author, Test', + language: null, + authority: null, + display: 'Author, Test', + confidence: -1, + place: 0 + } + ], + 'dc.title': [ + { + value: 'Title Test', + language: null, + authority: null, + display: 'Title Test', + confidence: -1, + place: 0 + } + ], + 'dc.date.issued': [ + { + value: '2015', + language: null, + authority: null, + display: '2015', + confidence: -1, + place: 0 + } + ] + } as any; + + const action = new UpdateSectionDataAction(submissionId, 'traditionalpageone', data, []); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.traditionalpageone.data).toEqual(data); + }); + + it('should add submission section errors properly', () => { + const errors = [ + { + path: '/sections/license', + message: 'error.validation.license.notgranted' + } + ]; + + const action = new UpdateSectionDataAction(submissionId, 'traditionalpageone', {}, errors); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.traditionalpageone.errors).toEqual(errors); + }); + + it('should remove all submission section errors properly', () => { + const action: any = new RemoveSectionErrorsAction(submissionId, 'traditionalpageone'); + let newState; + + newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.traditionalpageone.errors).toEqual([]); + }); + + it('should add submission section error properly', () => { + const error = { + path: '/sections/traditionalpageone/dc.title/0', + message: 'error.validation.traditionalpageone.required' + }; + + const action = new InertSectionErrorsAction(submissionId, 'traditionalpageone', error); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.traditionalpageone.errors).toEqual([error]); + }); + + it('should remove specified submission section error/s properly', () => { + const errors = [ + { + path: '/sections/traditionalpageone/dc.contributor.author', + message: 'error.validation.required' + }, + { + path: '/sections/traditionalpageone/dc.date.issued', + message: 'error.validation.required' + } + ]; + const error = { + path: '/sections/traditionalpageone/dc.contributor.author', + message: 'error.validation.required' + }; + + const expectedErrors = [{ + path: '/sections/traditionalpageone/dc.date.issued', + message: 'error.validation.required' + }]; + + let action: any = new UpdateSectionDataAction(submissionId, 'traditionalpageone', {}, errors); + let newState = submissionObjectReducer(initState, action); + + action = new DeleteSectionErrorsAction(submissionId, 'traditionalpageone', error); + newState = submissionObjectReducer(newState, action); + + expect(newState[826].sections.traditionalpageone.errors).toEqual(expectedErrors); + + action = new UpdateSectionDataAction(submissionId, 'traditionalpageone', {}, errors); + newState = submissionObjectReducer(initState, action); + + action = new DeleteSectionErrorsAction(submissionId, 'traditionalpageone', errors); + newState = submissionObjectReducer(newState, action); + + expect(newState[826].sections.traditionalpageone.errors).toEqual([]); + }); + + it('should add a new file', () => { + const uuid = '8cd86fba-70c8-483d-838a-70d28e7ed570'; + const fileData: any = { + uuid: uuid, + metadata: { + 'dc.title': [ + { + value: '28297_389341539060_6452876_n.jpg', + language: null, + authority: null, + display: '28297_389341539060_6452876_n.jpg', + confidence: -1, + place: 0 + } + ] + }, + accessConditions: [], + format: { + id: 16, + shortDescription: 'JPEG', + description: 'Joint Photographic Experts Group/JPEG File Interchange Format (JFIF)', + mimetype: 'image/jpeg', + supportLevel: 0, + internal: false, + extensions: null, + type: 'bitstreamformat' + }, + sizeBytes: 22737, + checkSum: { + checkSumAlgorithm: 'MD5', + value: '8722864dd671912f94a999ac7c4949d2' + }, + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content' + }; + const expectedState = { + files: [fileData] + }; + + const action = new NewUploadedFileAction(submissionId, 'upload', uuid, fileData); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.upload.data).toEqual(expectedState); + }); + + it('should remove a file', () => { + const uuid = '8cd86fba-70c8-483d-838a-70d28e7ed570'; + const uuid2 = '7e2f4ba9-9316-41fd-844a-1ef435f41a42'; + const fileData: any = { + uuid: uuid, + metadata: { + 'dc.title': [ + { + value: 'image_test.jpg', + language: null, + authority: null, + display: 'image_test.jpg', + confidence: -1, + place: 0 + } + ] + }, + accessConditions: [], + format: { + id: 16, + shortDescription: 'JPEG', + description: 'Joint Photographic Experts Group/JPEG File Interchange Format (JFIF)', + mimetype: 'image/jpeg', + supportLevel: 0, + internal: false, + extensions: null, + type: 'bitstreamformat' + }, + sizeBytes: 22737, + checkSum: { + checkSumAlgorithm: 'MD5', + value: '8722864dd671912f94a999ac7c4949d2' + }, + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content' + }; + const fileData2: any = { + uuid: uuid2, + metadata: { + 'dc.title': [ + { + value: 'image_test.jpg', + language: null, + authority: null, + display: 'image_test.jpg', + confidence: -1, + place: 0 + } + ] + }, + accessConditions: [], + format: { + id: 16, + shortDescription: 'JPEG', + description: 'Joint Photographic Experts Group/JPEG File Interchange Format (JFIF)', + mimetype: 'image/jpeg', + supportLevel: 0, + internal: false, + extensions: null, + type: 'bitstreamformat' + }, + sizeBytes: 22737, + checkSum: { + checkSumAlgorithm: 'MD5', + value: '8722864dd671912f94a999ac7c4949d2' + }, + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/7e2f4ba9-9316-41fd-844a-1ef435f41a42/content' + }; + + const state: SubmissionObjectState = Object.assign({}, initState, { + [submissionId]: Object.assign({}, initState[submissionId], { + sections: Object.assign({}, initState[submissionId].sections, { + upload: Object.assign({}, initState[submissionId].sections.upload, { + data: { + files: [fileData, fileData2] + } + }) + }) + }) + }); + + const expectedState = { + files: [fileData] + }; + + const action = new DeleteUploadedFileAction(submissionId, 'upload', uuid2); + const newState = submissionObjectReducer(state, action); + + expect(newState[826].sections.upload.data).toEqual(expectedState); + }); + + it('should edit file data', () => { + const uuid = '8cd86fba-70c8-483d-838a-70d28e7ed570'; + const fileData: any = { + uuid: uuid, + metadata: { + 'dc.title': [ + { + value: 'image_test.jpg', + language: null, + authority: null, + display: 'image_test.jpg', + confidence: -1, + place: 0 + } + ] + }, + accessConditions: [], + format: { + id: 16, + shortDescription: 'JPEG', + description: 'Joint Photographic Experts Group/JPEG File Interchange Format (JFIF)', + mimetype: 'image/jpeg', + supportLevel: 0, + internal: false, + extensions: null, + type: 'bitstreamformat' + }, + sizeBytes: 22737, + checkSum: { + checkSumAlgorithm: 'MD5', + value: '8722864dd671912f94a999ac7c4949d2' + }, + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/8cd86fba-70c8-483d-838a-70d28e7ed570/content' + }; + const fileData2: any = { + uuid: uuid, + metadata: { + 'dc.title': [ + { + value: 'New title', + language: null, + authority: null, + display: 'New title', + confidence: -1, + place: 0 + } + ] + }, + accessConditions: [], + format: { + id: 16, + shortDescription: 'JPEG', + description: 'Joint Photographic Experts Group/JPEG File Interchange Format (JFIF)', + mimetype: 'image/jpeg', + supportLevel: 0, + internal: false, + extensions: null, + type: 'bitstreamformat' + }, + sizeBytes: 22737, + checkSum: { + checkSumAlgorithm: 'MD5', + value: '8722864dd671912f94a999ac7c4949d2' + }, + url: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/7e2f4ba9-9316-41fd-844a-1ef435f41a42/content' + }; + + const state: SubmissionObjectState = Object.assign({}, initState, { + [submissionId]: Object.assign({}, initState[submissionId], { + sections: Object.assign({}, initState[submissionId].sections, { + upload: Object.assign({}, initState[submissionId].sections.upload, { + data: { + files: [fileData] + } + }) + }) + }) + }); + + const expectedState = { + files: [fileData2] + }; + + const action = new EditFileDataAction(submissionId, 'upload', uuid, fileData2); + const newState = submissionObjectReducer(state, action); + + expect(newState[826].sections.upload.data).toEqual(expectedState); + }); + +}); diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts new file mode 100644 index 0000000000..1a65783945 --- /dev/null +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -0,0 +1,841 @@ +import { hasValue, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; +import { differenceWith, findKey, isEqual, uniqWith } from 'lodash'; + +import { + ChangeSubmissionCollectionAction, + CompleteInitSubmissionFormAction, + DeleteSectionErrorsAction, + DeleteUploadedFileAction, + DepositSubmissionAction, + DepositSubmissionErrorAction, + DepositSubmissionSuccessAction, + DisableSectionAction, + EditFileDataAction, + EnableSectionAction, + InertSectionErrorsAction, + InitSectionAction, + InitSubmissionFormAction, + NewUploadedFileAction, + RemoveSectionErrorsAction, + ResetSubmissionFormAction, + SaveAndDepositSubmissionAction, + SaveForLaterSubmissionFormAction, + SaveForLaterSubmissionFormErrorAction, + SaveForLaterSubmissionFormSuccessAction, + SaveSubmissionFormAction, + SaveSubmissionFormErrorAction, + SaveSubmissionFormSuccessAction, + SaveSubmissionSectionFormAction, + SaveSubmissionSectionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, + SectionStatusChangeAction, + SetActiveSectionAction, + SubmissionObjectAction, + SubmissionObjectActionTypes, + UpdateSectionDataAction +} from './submission-objects.actions'; +import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model'; +import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; +import { SectionsType } from '../sections/sections-type'; + +/** + * An interface to represent section visibility + */ +export interface SectionVisibility { + main: any; + other: any; +} + +/** + * An interface to represent section object state + */ +export interface SubmissionSectionObject { + /** + * The section header + */ + header: string; + + /** + * The section configuration url + */ + config: string; + + /** + * A boolean representing if this section is mandatory + */ + mandatory: boolean; + + /** + * The section type + */ + sectionType: SectionsType; + + /** + * The section visibility + */ + visibility: SectionVisibility; + + /** + * A boolean representing if this section is collapsed + */ + collapsed: boolean, + + /** + * A boolean representing if this section is enabled + */ + enabled: boolean; + + /** + * The section data object + */ + data: WorkspaceitemSectionDataType; + + /** + * The list of the section errors + */ + errors: SubmissionSectionError[]; + + /** + * A boolean representing if this section is loading + */ + isLoading: boolean; + + /** + * A boolean representing if this section is valid + */ + isValid: boolean; +} + +/** + * An interface to represent section error + */ +export interface SubmissionSectionError { + /** + * A string representing error path + */ + path: string; + + /** + * The error message + */ + message: string; +} + +/** + * An interface to represent SubmissionSectionObject entry + */ +export interface SubmissionSectionEntry { + [sectionId: string]: SubmissionSectionObject; +} + +/** + * An interface to represent submission object state + */ +export interface SubmissionObjectEntry { + /** + * The collection this submission belonging to + */ + collection?: string, + + /** + * The configuration name that define this submission + */ + definition?: string, + + /** + * The submission self url + */ + selfUrl?: string; + + /** + * The submission active section + */ + activeSection?: string; + + /** + * The list of submission's sections + */ + sections?: SubmissionSectionEntry; + + /** + * A boolean representing if this submission is loading + */ + isLoading?: boolean; + + /** + * A boolean representing if a submission save operation is pending + */ + savePending?: boolean; + + /** + * A boolean representing if a submission deposit operation is pending + */ + depositPending?: boolean; +} + +/** + * The Submission State + * + * Consists of a map with submission's ID as key, + * and SubmissionObjectEntries as values + */ +export interface SubmissionObjectState { + [submissionId: string]: SubmissionObjectEntry; +} + +const initialState: SubmissionObjectState = Object.create({}); + +export function submissionObjectReducer(state = initialState, action: SubmissionObjectAction): SubmissionObjectState { + switch (action.type) { + + // submission form actions + case SubmissionObjectActionTypes.COMPLETE_INIT_SUBMISSION_FORM: { + return completeInit(state, action as CompleteInitSubmissionFormAction); + } + + case SubmissionObjectActionTypes.INIT_SUBMISSION_FORM: { + return initSubmission(state, action as InitSubmissionFormAction); + } + + case SubmissionObjectActionTypes.RESET_SUBMISSION_FORM: { + return resetSubmission(state, action as ResetSubmissionFormAction); + } + + case SubmissionObjectActionTypes.CANCEL_SUBMISSION_FORM: { + return initialState; + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM: + case SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM: + case SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION: + case SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM: { + return saveSubmission(state, action as SaveSubmissionFormAction); + } + + case SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS: + case SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS: + case SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR: + case SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_ERROR: + case SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR: { + return completeSave(state, action as SaveSubmissionFormErrorAction); + } + + case SubmissionObjectActionTypes.CHANGE_SUBMISSION_COLLECTION: { + return changeCollection(state, action as ChangeSubmissionCollectionAction); + } + + case SubmissionObjectActionTypes.DEPOSIT_SUBMISSION: { + return startDeposit(state, action as DepositSubmissionAction); + } + + case SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_SUCCESS: { + return initialState; + } + + case SubmissionObjectActionTypes.DEPOSIT_SUBMISSION_ERROR: { + return endDeposit(state, action as DepositSubmissionAction); + } + + case SubmissionObjectActionTypes.DISCARD_SUBMISSION: { + return state; + } + + case SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS: { + return initialState; + } + + case SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR: { + return state; + } + + case SubmissionObjectActionTypes.SET_ACTIVE_SECTION: { + return setActiveSection(state, action as SetActiveSectionAction); + } + + // Section actions + + case SubmissionObjectActionTypes.INIT_SECTION: { + return initSection(state, action as InitSectionAction); + } + + case SubmissionObjectActionTypes.ENABLE_SECTION: { + return changeSectionState(state, action as EnableSectionAction, true); + } + + case SubmissionObjectActionTypes.UPLOAD_SECTION_DATA: { + return updateSectionData(state, action as UpdateSectionDataAction); + } + + case SubmissionObjectActionTypes.DISABLE_SECTION: { + return changeSectionState(state, action as DisableSectionAction, false); + } + + case SubmissionObjectActionTypes.SECTION_STATUS_CHANGE: { + return setIsValid(state, action as SectionStatusChangeAction); + } + + // Files actions + case SubmissionObjectActionTypes.NEW_FILE: { + return newFile(state, action as NewUploadedFileAction); + } + + case SubmissionObjectActionTypes.EDIT_FILE_DATA: { + return editFileData(state, action as EditFileDataAction); + } + + case SubmissionObjectActionTypes.DELETE_FILE: { + return deleteFile(state, action as DeleteUploadedFileAction); + } + + // errors actions + case SubmissionObjectActionTypes.ADD_SECTION_ERROR: { + return addError(state, action as InertSectionErrorsAction); + } + + case SubmissionObjectActionTypes.DELETE_SECTION_ERROR: { + return removeError(state, action as DeleteSectionErrorsAction); + } + + case SubmissionObjectActionTypes.REMOVE_SECTION_ERRORS: { + return removeSectionErrors(state, action as RemoveSectionErrorsAction); + } + + default: { + return state; + } + } +} + +// ------ Submission error functions ------ // + +const removeError = (state: SubmissionObjectState, action: DeleteSectionErrorsAction): SubmissionObjectState => { + const { submissionId, sectionId, errors } = action.payload; + + if (hasValue(state[ submissionId ].sections[ sectionId ])) { + let filteredErrors; + + if (Array.isArray(errors)) { + filteredErrors = differenceWith(errors, errors, isEqual); + } else { + filteredErrors = state[ submissionId ].sections[ sectionId ].errors + .filter((currentError) => currentError.path !== errors.path || !isEqual(currentError, errors)); + } + + return Object.assign({}, state, { + [ submissionId ]: Object.assign({}, state[ submissionId ], { + sections: Object.assign({}, state[ submissionId ].sections, { + [ sectionId ]: Object.assign({}, state[ submissionId ].sections [ sectionId ], { + errors: filteredErrors + }) + }) + }) + }); + } else { + return state; + } +}; + +const addError = (state: SubmissionObjectState, action: InertSectionErrorsAction): SubmissionObjectState => { + const { submissionId, sectionId, error } = action.payload; + + if (hasValue(state[ submissionId ].sections[ sectionId ])) { + const errors = uniqWith(state[ submissionId ].sections[ sectionId ].errors.concat(error), isEqual); + + return Object.assign({}, state, { + [ submissionId ]: Object.assign({}, state[ submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, sections: Object.assign({}, state[ submissionId ].sections, { + [ sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + errors + }) + }), + }) + }); + } else { + return state; + } +}; + +/** + * Remove all section's errors. + * + * @param state + * the current state + * @param action + * an RemoveSectionErrorsAction + * @return SubmissionObjectState + * the new state, with the section's errors updated. + */ +function removeSectionErrors(state: SubmissionObjectState, action: RemoveSectionErrorsAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) + && isNotEmpty(state[ action.payload.submissionId ].sections[ action.payload.sectionId])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + errors: [] + }) + }) + }) + }); + } else { + return state; + } +} + +// ------ Submission functions ------ // + +/** + * Init a SubmissionObjectState. + * + * @param state + * the current state + * @param action + * an InitSubmissionFormAction | ResetSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function initSubmission(state: SubmissionObjectState, action: InitSubmissionFormAction | ResetSubmissionFormAction): SubmissionObjectState { + + const newState = Object.assign({}, state); + newState[ action.payload.submissionId ] = { + collection: action.payload.collectionId, + definition: action.payload.submissionDefinition.name, + selfUrl: action.payload.selfUrl, + activeSection: null, + sections: Object.create(null), + isLoading: true, + savePending: false, + depositPending: false, + }; + return newState; +} + +/** + * Reset submission. + * + * @param state + * the current state + * @param action + * an ResetSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function resetSubmission(state: SubmissionObjectState, action: ResetSubmissionFormAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.create(null), + isLoading: true + }) + }); + } else { + return state; + } +} + +/** + * Set submission loading to false. + * + * @param state + * the current state + * @param action + * an CompleteInitSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function completeInit(state: SubmissionObjectState, action: CompleteInitSubmissionFormAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + isLoading: false + }) + }); + } else { + return state; + } +} + +/** + * Set submission save flag to true + * + * @param state + * the current state + * @param action + * an SaveSubmissionFormAction | SaveSubmissionSectionFormAction + * | SaveForLaterSubmissionFormAction | SaveAndDepositSubmissionAction + * @return SubmissionObjectState + * the new state, with the flag set to true. + */ +function saveSubmission(state: SubmissionObjectState, + action: SaveSubmissionFormAction + | SaveSubmissionSectionFormAction + | SaveForLaterSubmissionFormAction + | SaveAndDepositSubmissionAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, + sections: state[ action.payload.submissionId ].sections, + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: true, + }) + }); + } else { + return state; + } +} + +/** + * Set submission save flag to false. + * + * @param state + * the current state + * @param action + * an SaveSubmissionFormSuccessAction | SaveForLaterSubmissionFormSuccessAction + * | SaveSubmissionSectionFormSuccessAction | SaveSubmissionFormErrorAction + * | SaveForLaterSubmissionFormErrorAction | SaveSubmissionSectionFormErrorAction + * @return SubmissionObjectState + * the new state, with the flag set to false. + */ +function completeSave(state: SubmissionObjectState, + action: SaveSubmissionFormSuccessAction + | SaveForLaterSubmissionFormSuccessAction + | SaveSubmissionSectionFormSuccessAction + | SaveSubmissionFormErrorAction + | SaveForLaterSubmissionFormErrorAction + | SaveSubmissionSectionFormErrorAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + savePending: false, + }) + }); + } else { + return state; + } +} + +/** + * Set deposit flag to true + * + * @param state + * the current state + * @param action + * an DepositSubmissionAction + * @return SubmissionObjectState + * the new state, with the deposit flag changed. + */ +function startDeposit(state: SubmissionObjectState, action: DepositSubmissionAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + savePending: false, + depositPending: true, + }) + }); + } else { + return state; + } +} + +/** + * Set deposit flag to false + * + * @param state + * the current state + * @param action + * an DepositSubmissionSuccessAction or DepositSubmissionErrorAction + * @return SubmissionObjectState + * the new state, with the deposit flag changed. + */ +function endDeposit(state: SubmissionObjectState, action: DepositSubmissionSuccessAction | DepositSubmissionErrorAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + depositPending: false, + }) + }); + } else { + return state; + } +} + +/** + * Init a SubmissionObjectState. + * + * @param state + * the current state + * @param action + * an InitSubmissionFormAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function changeCollection(state: SubmissionObjectState, action: ChangeSubmissionCollectionAction): SubmissionObjectState { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + collection: action.payload.collectionId + }) + }); +} + +// ------ Section functions ------ // + +/** + * Set submission active section. + * + * @param state + * the current state + * @param action + * an SetActiveSectionAction + * @return SubmissionObjectState + * the new state, with the active section. + */ +function setActiveSection(state: SubmissionObjectState, action: SetActiveSectionAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: action.payload.sectionId, + sections: state[ action.payload.submissionId ].sections, + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: state[ action.payload.submissionId ].savePending, + }) + }); + } else { + return state; + } +} + +/** + * Set a section enabled. + * + * @param state + * the current state + * @param action + * an InitSectionAction + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function initSection(state: SubmissionObjectState, action: InitSectionAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: { + header: action.payload.header, + config: action.payload.config, + mandatory: action.payload.mandatory, + sectionType: action.payload.sectionType, + visibility: action.payload.visibility, + collapsed: false, + enabled: action.payload.enabled, + data: action.payload.data, + errors: action.payload.errors || [], + isLoading: false, + isValid: false + } + }) + }) + }); + } else { + return state; + } +} + +/** + * Update section's data. + * + * @param state + * the current state + * @param action + * an UpdateSectionDataAction + * @return SubmissionObjectState + * the new state, with the section's data updated. + */ +function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDataAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) + && isNotEmpty(state[ action.payload.submissionId ].sections[ action.payload.sectionId])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + enabled: true, + data: action.payload.data, + errors: action.payload.errors + }) + }) + }) + }); + } else { + return state; + } +} + +/** + * Set a section state. + * + * @param state + * the current state + * @param action + * an DisableSectionAction + * @param enabled + * enabled or disabled section. + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function changeSectionState(state: SubmissionObjectState, action: EnableSectionAction | DisableSectionAction, enabled: boolean): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ].sections[ action.payload.sectionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + // sections: deleteProperty(state[ action.payload.submissionId ].sections, action.payload.sectionId), + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + enabled + }) + }) + }) + }); + } else { + return state; + } +} + +/** + * Set the section validity. + * + * @param state + * the current state + * @param action + * an SectionStatusChangeAction + * @return SubmissionObjectState + * the new state, with the section new validity status. + */ +function setIsValid(state: SubmissionObjectState, action: SectionStatusChangeAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) && hasValue(state[ action.payload.submissionId ].sections[ action.payload.sectionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, + Object.assign({}, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + isValid: action.payload.status + }) + }) + ) + }) + }); + } else { + return state; + } +} + +// ------ Upload file functions ------ // + +/** + * Set a new file. + * + * @param state + * the current state + * @param action + * a NewUploadedFileAction action + * @return SubmissionObjectState + * the new state, with the new file. + */ +function newFile(state: SubmissionObjectState, action: NewUploadedFileAction): SubmissionObjectState { + const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + let newData; + if (isUndefined(filesData.files)) { + newData = { + files: [action.payload.data] + }; + } else { + newData = filesData; + newData.files.push(action.payload.data) + } + + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + enabled: true, + data: newData + }) + }) + }) + }); +} + +/** + * Edit a file. + * + * @param state + * the current state + * @param action + * a EditFileDataAction action + * @return SubmissionObjectState + * the new state, with the edited file. + */ +function editFileData(state: SubmissionObjectState, action: EditFileDataAction): SubmissionObjectState { + const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + if (hasValue(filesData.files)) { + const fileIndex = findKey( + filesData.files, + { uuid: action.payload.fileId }); + if (isNotNull(fileIndex)) { + const newData = Array.from(filesData.files); + newData[fileIndex] = action.payload.data; + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + activeSection: state[ action.payload.submissionId ].activeSection, + sections: Object.assign({}, state[ action.payload.submissionId ].sections, + Object.assign({}, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { + files: newData + }) + }) + }) + ), + isLoading: state[ action.payload.submissionId ].isLoading, + savePending: state[ action.payload.submissionId ].savePending, + }) + }); + } + } + return state; +} + +/** + * Delete a file. + * + * @param state + * the current state + * @param action + * a DeleteUploadedFileAction action + * @return SubmissionObjectState + * the new state, with the file removed. + */ +function deleteFile(state: SubmissionObjectState, action: DeleteUploadedFileAction): SubmissionObjectState { + const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + if (hasValue(filesData.files)) { + const fileIndex: any = findKey( + filesData.files, + {uuid: action.payload.fileId}); + if (isNotNull(fileIndex)) { + const newData = Array.from(filesData.files); + newData.splice(fileIndex, 1); + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[action.payload.submissionId], { + sections: Object.assign({}, state[action.payload.submissionId].sections, + Object.assign({}, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ], { + data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { + files: newData + }) + }) + }) + ) + }) + }); + } + } + return state; +} diff --git a/src/app/submission/sections/container/section-container.component.html b/src/app/submission/sections/container/section-container.component.html new file mode 100644 index 0000000000..fb29f606e6 --- /dev/null +++ b/src/app/submission/sections/container/section-container.component.html @@ -0,0 +1,49 @@ +
    + + + + {{ 'submission.sections.'+sectionData.header | translate }} +
    + + + + + + + + + + +
    +
    + +
    + +
    +
    + +
    +
    +
    +
    +
    diff --git a/src/app/submission/sections/container/section-container.component.scss b/src/app/submission/sections/container/section-container.component.scss new file mode 100644 index 0000000000..2e17794e42 --- /dev/null +++ b/src/app/submission/sections/container/section-container.component.scss @@ -0,0 +1,23 @@ +@import '../../../../styles/variables'; + +:host /deep/ .card { + margin-bottom: $submission-sections-margin-bottom; + overflow: unset; +} + +.section-focus { + border-radius: $border-radius; + box-shadow: $btn-focus-box-shadow; +} + +// TODO to remove the following when upgrading @ng-bootstrap +:host /deep/ .card:first-of-type { + border-bottom: $card-border-width solid $card-border-color !important; + border-bottom-left-radius: $card-border-radius !important; + border-bottom-right-radius: $card-border-radius !important; +} + +:host /deep/ .card-header button { + box-shadow: none !important; + width: 100%; +} diff --git a/src/app/submission/sections/container/section-container.component.spec.ts b/src/app/submission/sections/container/section-container.component.spec.ts new file mode 100644 index 0000000000..778aa4ab84 --- /dev/null +++ b/src/app/submission/sections/container/section-container.component.spec.ts @@ -0,0 +1,234 @@ +// Load the implementations that should be tested +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { SubmissionSectionContainerComponent } from './section-container.component'; +import { createTestComponent } from '../../../shared/testing/utils'; +import { SectionsType } from '../sections-type'; +import { SectionsDirective } from '../sections.directive'; +import { SubmissionService } from '../../submission.service'; +import { SectionsService } from '../sections.service'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service-stub'; +import { SectionDataObject } from '../models/section-data.model'; +import { mockSubmissionCollectionId, mockSubmissionId } from '../../../shared/mocks/mock-submission'; + +const sectionState = { + header: 'submit.progressbar.describe.stepone', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false +} as any; + +const sectionObject: SectionDataObject = { + config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/traditionalpageone', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.describe.stepone', + id: 'traditionalpageone', + sectionType: SectionsType.SubmissionForm +}; + +describe('SubmissionSectionContainerComponent test suite', () => { + + let comp: SubmissionSectionContainerComponent; + let compAsAny: any; + let fixture: ComponentFixture; + + let submissionServiceStub: SubmissionServiceStub; + let sectionsServiceStub: SectionsServiceStub; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + + function init() { + sectionsServiceStub = TestBed.get(SectionsService); + submissionServiceStub = TestBed.get(SubmissionService); + + sectionsServiceStub.isSectionValid.and.returnValue(observableOf(true)); + sectionsServiceStub.getSectionState.and.returnValue(observableOf(sectionState)); + submissionServiceStub.getActiveSectionId.and.returnValue(observableOf('traditionalpageone')); + } + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + SubmissionSectionContainerComponent, + SectionsDirective, + TestComponent, + ], // declare the test component + providers: [ + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + SubmissionSectionContainerComponent + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + let html; + + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + init(); + }); + + it('should create SubmissionSectionContainerComponent', inject([SubmissionSectionContainerComponent], (app: SubmissionSectionContainerComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('', () => { + beforeEach(() => { + init(); + fixture = TestBed.createComponent(SubmissionSectionContainerComponent); + comp = fixture.componentInstance; + compAsAny = comp; + comp.submissionId = submissionId; + comp.collectionId = collectionId; + comp.sectionData = sectionObject; + + spyOn(comp, 'getSectionContent'); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should inject section properly', () => { + spyOn(comp.sectionRef, 'isEnabled').and.returnValue(observableOf(true)); + spyOn(comp.sectionRef, 'hasGenericErrors').and.returnValue(false); + + comp.ngOnInit(); + fixture.detectChanges(); + + const section = fixture.debugElement.query(By.css('[id^=\'sectionContent_\']')); + expect(comp.getSectionContent).toHaveBeenCalled(); + expect(section).toBeDefined(); + }); + + it('should call removeSection properly', () => { + + const mockEvent = jasmine.createSpyObj('event', { + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + }); + spyOn(comp.sectionRef, 'removeSection'); + comp.removeSection(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(comp.sectionRef.removeSection).toHaveBeenCalledWith(submissionId, 'traditionalpageone'); + }); + + it('should display generic section errors div', () => { + let sectionErrorsDiv = fixture.debugElement.query(By.css('[id^=\'sectionGenericError_\']')); + expect(sectionErrorsDiv).toBeNull(); + + spyOn(comp.sectionRef, 'isEnabled').and.returnValue(observableOf(true)); + spyOn(comp.sectionRef, 'hasGenericErrors').and.returnValue(true); + + comp.ngOnInit(); + fixture.detectChanges(); + + sectionErrorsDiv = fixture.debugElement.query(By.css('[id^=\'sectionGenericError_\']')); + expect(sectionErrorsDiv).toBeDefined(); + }); + + it('should display warning icon', () => { + + spyOn(comp.sectionRef, 'isEnabled').and.returnValue(observableOf(true)); + spyOn(comp.sectionRef, 'isValid').and.returnValue(observableOf(false)); + spyOn(comp.sectionRef, 'hasErrors').and.returnValue(false); + + comp.ngOnInit(); + fixture.detectChanges(); + + const iconWarn = fixture.debugElement.query(By.css('i.text-warning')); + const iconErr = fixture.debugElement.query(By.css('i.text-danger')); + const iconSuccess = fixture.debugElement.query(By.css('i.text-success')); + expect(iconWarn).toBeDefined(); + expect(iconErr).toBeNull(); + expect(iconSuccess).toBeNull(); + }); + + it('should display error icon', () => { + + spyOn(comp.sectionRef, 'isEnabled').and.returnValue(observableOf(true)); + spyOn(comp.sectionRef, 'isValid').and.returnValue(observableOf(false)); + spyOn(comp.sectionRef, 'hasErrors').and.returnValue(true); + + comp.ngOnInit(); + fixture.detectChanges(); + + const iconWarn = fixture.debugElement.query(By.css('i.text-warning')); + const iconErr = fixture.debugElement.query(By.css('i.text-danger')); + const iconSuccess = fixture.debugElement.query(By.css('i.text-success')); + expect(iconWarn).toBeNull(); + expect(iconErr).toBeDefined(); + expect(iconSuccess).toBeNull(); + }); + + it('should display success icon', () => { + + spyOn(comp.sectionRef, 'isEnabled').and.returnValue(observableOf(true)); + spyOn(comp.sectionRef, 'isValid').and.returnValue(observableOf(true)); + spyOn(comp.sectionRef, 'hasErrors').and.returnValue(false); + + comp.ngOnInit(); + fixture.detectChanges(); + + const iconWarn = fixture.debugElement.query(By.css('i.text-warning')); + const iconErr = fixture.debugElement.query(By.css('i.text-danger')); + const iconSuccess = fixture.debugElement.query(By.css('i.text-success')); + expect(iconWarn).toBeNull(); + expect(iconErr).toBeNull(); + expect(iconSuccess).toBeDefined(); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + public collectionId = '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb'; + public submissionId = mockSubmissionId; + public object = sectionObject; +} diff --git a/src/app/submission/sections/container/section-container.component.ts b/src/app/submission/sections/container/section-container.component.ts new file mode 100644 index 0000000000..f040288667 --- /dev/null +++ b/src/app/submission/sections/container/section-container.component.ts @@ -0,0 +1,93 @@ +import { Component, Injector, Input, OnInit, ViewChild } from '@angular/core'; + +import { SectionsDirective } from '../sections.directive'; +import { SectionDataObject } from '../models/section-data.model'; +import { rendersSectionType } from '../sections-decorator'; +import { AlertType } from '../../../shared/alert/aletr-type'; + +/** + * This component represents a section that contains the submission license form. + */ +@Component({ + selector: 'ds-submission-section-container', + templateUrl: './section-container.component.html', + styleUrls: ['./section-container.component.scss'] +}) +export class SubmissionSectionContainerComponent implements OnInit { + + /** + * The collection id this submission belonging to + * @type {string} + */ + @Input() collectionId: string; + + /** + * The section data + * @type {SectionDataObject} + */ + @Input() sectionData: SectionDataObject; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * The AlertType enumeration + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * Injector to inject a section component with the @Input parameters + * @type {Injector} + */ + public objectInjector: Injector; + + /** + * The SectionsDirective reference + */ + @ViewChild('sectionRef') sectionRef: SectionsDirective; + + /** + * Initialize instance variables + * + * @param {Injector} injector + */ + constructor(private injector: Injector) { + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.objectInjector = Injector.create({ + providers: [ + {provide: 'collectionIdProvider', useFactory: () => (this.collectionId), deps: []}, + {provide: 'sectionDataProvider', useFactory: () => (this.sectionData), deps: []}, + {provide: 'submissionIdProvider', useFactory: () => (this.submissionId), deps: []}, + ], + parent: this.injector + }); + } + + /** + * Remove section from submission form + * + * @param event + * the event emitted + */ + public removeSection(event) { + event.preventDefault(); + event.stopPropagation(); + this.sectionRef.removeSection(this.submissionId, this.sectionData.id); + } + + /** + * Find the correct component based on the section's type + */ + getSectionContent(): string { + return rendersSectionType(this.sectionData.sectionType); + } +} diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts new file mode 100644 index 0000000000..c90fc62360 --- /dev/null +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -0,0 +1,761 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { + DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + DYNAMIC_FORM_CONTROL_TYPE_GROUP, + DynamicFormControlEvent +} from '@ng-dynamic-forms/core'; + +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { getMockFormBuilderService } from '../../../shared/mocks/mock-form-builder-service'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader'; +import { SectionFormOperationsService } from './section-form-operations.service'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; +import { + mockInputWithAuthorityValueModel, + mockInputWithFormFieldValueModel, + mockInputWithLanguageAndAuthorityArrayModel, + mockInputWithLanguageAndAuthorityModel, + mockInputWithLanguageModel, + mockInputWithObjectValueModel, + mockQualdropInputModel, + MockQualdropModel, + MockRelationModel, + mockRowGroupModel +} from '../../../shared/mocks/mock-form-models'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { AuthorityValue } from '../../../core/integration/models/authority.value'; + +describe('SectionFormOperationsService test suite', () => { + let formBuilderService: any; + let service: SectionFormOperationsService; + let serviceAsAny: any; + + const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), + }); + const pathCombiner = new JsonPatchOperationPathCombiner('sections', 'test'); + + const dynamicFormControlChangeEvent: DynamicFormControlEvent = { + $event: new Event('change'), + context: null, + control: null, + group: null, + model: null, + type: 'change' + }; + + const dynamicFormControlRemoveEvent: DynamicFormControlEvent = { + $event: new Event('change'), + context: null, + control: null, + group: null, + model: null, + type: 'remove' + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + providers: [ + { provide: FormBuilderService, useValue: getMockFormBuilderService() }, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + SectionFormOperationsService + ] + }).compileComponents().then(); + })); + + beforeEach(() => { + service = TestBed.get(SectionFormOperationsService); + serviceAsAny = service; + formBuilderService = TestBed.get(FormBuilderService); + }); + + describe('dispatchOperationsFromEvent', () => { + it('should call dispatchOperationsFromRemoveEvent on remove event', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + spyOn(serviceAsAny, 'dispatchOperationsFromRemoveEvent'); + + service.dispatchOperationsFromEvent(pathCombiner, dynamicFormControlRemoveEvent, previousValue, true); + + expect(serviceAsAny.dispatchOperationsFromRemoveEvent).toHaveBeenCalled(); + }); + + it('should call dispatchOperationsFromChangeEvent on change event', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + spyOn(serviceAsAny, 'dispatchOperationsFromChangeEvent'); + + service.dispatchOperationsFromEvent(pathCombiner, dynamicFormControlChangeEvent, previousValue, true); + + expect(serviceAsAny.dispatchOperationsFromChangeEvent).toHaveBeenCalled(); + }); + }); + + describe('getArrayIndexFromEvent', () => { + it('should return the index of the array to which the element belongs', () => { + const event = Object.assign({}, dynamicFormControlChangeEvent, { + context: { + index: 1 + } + }); + + expect(service.getArrayIndexFromEvent(event)).toBe(1); + }); + + it('should return the index of the array to which the parent element belongs', () => { + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: { + parent: { + index: 2 + } + } + } + }); + spyOn(serviceAsAny, 'isPartOfArrayOfGroup').and.returnValue(true); + + expect(service.getArrayIndexFromEvent(event)).toBe(2); + }); + + it('should return zero when element doesn\'t belong to an array', () => { + + spyOn(serviceAsAny, 'isPartOfArrayOfGroup').and.returnValue(false); + + expect(service.getArrayIndexFromEvent(dynamicFormControlChangeEvent)).toBe(0); + }); + + it('should return zero when event is empty', () => { + + expect(service.getArrayIndexFromEvent(null)).toBe(0); + }); + + }); + + describe('isPartOfArrayOfGroup', () => { + it('should return true when parent element belongs to an array group element', () => { + const model = { + parent: { + type: DYNAMIC_FORM_CONTROL_TYPE_GROUP, + parent: { + context: { + type: DYNAMIC_FORM_CONTROL_TYPE_ARRAY + } + } + } + }; + + expect(service.isPartOfArrayOfGroup(model as any)).toBeTruthy(); + }); + + it('should return false when parent element doesn\'t belong to an array group element', () => { + const model = { + parent: null + }; + + expect(service.isPartOfArrayOfGroup(model as any)).toBeFalsy(); + }); + + }); + + describe('getQualdropValueMap', () => { + it('should return map properly', () => { + const context = { + groups: [ + { + group: [MockQualdropModel] + } + ] + }; + const model = { + parent: { + parent: { + context: context + } + } + }; + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: model + }); + const expectMap = new Map(); + expectMap.set(MockQualdropModel.qualdropId, [MockQualdropModel.value]); + + formBuilderService.isQualdropGroup.and.returnValue(false); + + expect(service.getQualdropValueMap(event)).toEqual(expectMap); + }); + + it('should return map properly when model is DynamicQualdropModel', () => { + const context = { + groups: [ + { + group: [MockQualdropModel] + } + ] + }; + const model = { + parent: { + context: context + } + }; + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: model + }); + const expectMap = new Map(); + expectMap.set(MockQualdropModel.qualdropId, [MockQualdropModel.value]); + + formBuilderService.isQualdropGroup.and.returnValue(true); + + expect(service.getQualdropValueMap(event)).toEqual(expectMap); + }); + }); + + describe('getFieldPathFromEvent', () => { + it('should field path properly', () => { + const spy = spyOn(serviceAsAny, 'getArrayIndexFromEvent'); + spy.and.returnValue(1); + spyOn(serviceAsAny, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + + expect(service.getFieldPathFromEvent(dynamicFormControlChangeEvent)).toBe('path/1'); + + spy.and.returnValue(undefined); + + expect(service.getFieldPathFromEvent(dynamicFormControlChangeEvent)).toBe('path'); + }); + }); + + describe('getQualdropItemPathFromEvent', () => { + it('should return path properly', () => { + const context = { + groups: [ + { + group: [MockQualdropModel] + } + ] + }; + const model = { + parent: { + parent: { + context: context + } + } + }; + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: model + }); + const expectPath = 'dc.identifier.issn/0'; + spyOn(serviceAsAny, 'getArrayIndexFromEvent').and.returnValue(0); + + formBuilderService.isQualdropGroup.and.returnValue(false); + + expect(service.getQualdropItemPathFromEvent(event)).toEqual(expectPath); + }); + + it('should return path properly when model is DynamicQualdropModel', () => { + const context = { + groups: [ + { + group: [MockQualdropModel] + } + ] + }; + const model = { + parent: { + context: context + } + }; + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: model + }); + const expectPath = 'dc.identifier.issn/0'; + spyOn(serviceAsAny, 'getArrayIndexFromEvent').and.returnValue(0); + + formBuilderService.isQualdropGroup.and.returnValue(true); + + expect(service.getQualdropItemPathFromEvent(event)).toEqual(expectPath); + }); + }); + + describe('getFieldPathSegmentedFromChangeEvent', () => { + it('should return field segmented path properly', () => { + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: mockQualdropInputModel + }); + formBuilderService.isQualdropGroup.and.returnValues(false, false); + + expect(service.getFieldPathSegmentedFromChangeEvent(event)).toEqual('path'); + }); + + it('should return field segmented path properly when model is DynamicQualdropModel', () => { + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: MockQualdropModel + }); + formBuilderService.isQualdropGroup.and.returnValue(true); + + expect(service.getFieldPathSegmentedFromChangeEvent(event)).toEqual('dc.identifier.issn'); + }); + + it('should return field segmented path properly when model belongs to a DynamicQualdropModel', () => { + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: MockQualdropModel + } + }); + formBuilderService.isQualdropGroup.and.returnValues(false, true); + + expect(service.getFieldPathSegmentedFromChangeEvent(event)).toEqual('dc.identifier.issn'); + }); + + }); + + describe('getFieldValueFromChangeEvent', () => { + it('should return field value properly when model belongs to a DynamicQualdropModel', () => { + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: MockQualdropModel + } + }); + formBuilderService.isModelInCustomGroup.and.returnValue(true); + const expectedValue = 'test'; + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + }); + + it('should return field value properly when model is DynamicRelationGroupModel', () => { + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: MockRelationModel + }); + formBuilderService.isModelInCustomGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(true); + const expectedValue = { + journal: [ + 'journal test 1', + 'journal test 2' + ], + issue: [ + 'issue test 1', + 'issue test 2' + ], + }; + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + }); + + it('should return field value properly when model has language', () => { + let event = Object.assign({}, dynamicFormControlChangeEvent, { + model: mockInputWithLanguageModel + }); + formBuilderService.isModelInCustomGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + let expectedValue: any = new FormFieldMetadataValueObject(mockInputWithLanguageModel.value, 'en_US'); + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + + event = Object.assign({}, dynamicFormControlChangeEvent, { + model: mockInputWithLanguageAndAuthorityModel + }); + expectedValue = Object.assign(new AuthorityValue(), mockInputWithLanguageAndAuthorityModel.value, {language: mockInputWithLanguageAndAuthorityModel.language}); + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + + event = Object.assign({}, dynamicFormControlChangeEvent, { + model: mockInputWithLanguageAndAuthorityArrayModel + }); + expectedValue = [ + Object.assign(new AuthorityValue(), mockInputWithLanguageAndAuthorityArrayModel.value[0], + { language: mockInputWithLanguageAndAuthorityArrayModel.language } + ) + ]; + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + }); + + it('should return field value properly when model has an object as value', () => { + let event = Object.assign({}, dynamicFormControlChangeEvent, { + model: mockInputWithFormFieldValueModel + }); + formBuilderService.isModelInCustomGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + let expectedValue: any = mockInputWithFormFieldValueModel.value; + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + + event = Object.assign({}, dynamicFormControlChangeEvent, { + model: mockInputWithAuthorityValueModel + }); + expectedValue = mockInputWithAuthorityValueModel.value; + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + + event = Object.assign({}, dynamicFormControlChangeEvent, { + model: mockInputWithObjectValueModel + }); + expectedValue = mockInputWithObjectValueModel.value; + + expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); + }); + }); + + describe('getValueMap', () => { + it('should return field segmented path properly when model belongs to a DynamicQualdropModel', () => { + const items = [ + { path: 'test1' }, + { path: 'test2' }, + { path1: 'test3' }, + ]; + const expectedMap = new Map(); + expectedMap.set('path', ['test1', 'test2']); + expectedMap.set('path1', ['test3']); + + expect(service.getValueMap(items)).toEqual(expectedMap); + }); + }); + + describe('dispatchOperationsFromRemoveEvent', () => { + it('should call dispatchOperationsFromMap when is Qualdrop Group model', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue('test'); + spyOn(serviceAsAny, 'getQualdropValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(true); + + serviceAsAny.dispatchOperationsFromRemoveEvent(pathCombiner, dynamicFormControlRemoveEvent, previousValue); + + expect(serviceAsAny.dispatchOperationsFromMap).toHaveBeenCalled(); + }); + + it('should dispatch a json-path remove operation', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue('test'); + formBuilderService.isQualdropGroup.and.returnValue(false); + + serviceAsAny.dispatchOperationsFromRemoveEvent(pathCombiner, dynamicFormControlRemoveEvent, previousValue); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalled(); + }); + + }); + + describe('dispatchOperationsFromChangeEvent', () => { + it('should call dispatchOperationsFromMap when is Qualdrop Group model', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: MockQualdropModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue('test'); + spyOn(serviceAsAny, 'getQualdropValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(true); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(serviceAsAny.getQualdropValueMap).toHaveBeenCalled(); + expect(serviceAsAny.dispatchOperationsFromMap).toHaveBeenCalled(); + }); + + it('should call dispatchOperationsFromMap when is Qualdrop Group model', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: MockRelationModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue('test'); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(true); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(serviceAsAny.getValueMap).toHaveBeenCalled(); + expect(serviceAsAny.dispatchOperationsFromMap).toHaveBeenCalled(); + }); + + it('should dispatch a json-path add operation', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue('test'); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(true); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('path'), 'test', true); + }); + + it('should dispatch a json-path remove operation when previous path is equal and there is no value', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + const spyPath = spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject()); + const spyIndex = spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path')); + + spyIndex.and.returnValue(1); + spyPath.and.returnValue('path/1'); + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path/1')); + }); + + it('should dispatch a json-path replace operation when previous path is equal and there is no value', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); + spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(jsonPatchOpBuilder.replace).toHaveBeenCalledWith( + pathCombiner.getPath('path/0'), + new FormFieldMetadataValueObject('test')); + }); + + it('should dispatch a json-path remove operation when has a stored value', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + const spyPath = spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject()); + const spyIndex = spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path')); + + spyIndex.and.returnValue(1); + spyPath.and.returnValue('path/1'); + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path/1')); + }); + + it('should dispatch a json-path replace operation when has a stored value', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); + spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); + + expect(jsonPatchOpBuilder.replace).toHaveBeenCalledWith( + pathCombiner.getPath('path/0'), + new FormFieldMetadataValueObject('test')); + }); + + it('should dispatch a json-path add operation when has a value and field index is zero or undefined', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + const spyPath = spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); + const spyIndex = spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + new FormFieldMetadataValueObject('test'), + true); + + spyIndex.and.returnValue(undefined); + spyPath.and.returnValue('path'); + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + new FormFieldMetadataValueObject('test'), + true); + }); + + it('should dispatch a json-path add operation when has a value', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/1'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); + spyOn(service, 'getArrayIndexFromEvent').and.returnValue(1); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, false); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path/1'), + new FormFieldMetadataValueObject('test')); + }); + }); + + describe('dispatchOperationsFromMap', () => { + it('should dispatch a json-path remove operation when event type is remove', () => { + const valueMap = new Map(); + valueMap.set('path', ['testMap']); + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], valueMap); + spyOn(serviceAsAny, 'getQualdropItemPathFromEvent').and.returnValue('path/test'); + + serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlRemoveEvent, previousValue); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path/test')); + }); + + it('should dispatch a json-path add operation when a map has a new entry', () => { + const valueMap = new Map(); + valueMap.set('path', ['testMapNew']); + const previousValueMap = new Map(); + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], previousValueMap); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlChangeEvent, previousValue); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('path'), ['testMapNew'], true); + }); + + it('should dispatch a json-path add operation when a map entry has changed', () => { + const valueMap = new Map(); + valueMap.set('path', ['testMapNew']); + const previousValueMap = new Map(); + previousValueMap.set('path', ['testMap']); + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], previousValueMap); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlChangeEvent, previousValue); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('path'), ['testMapNew'], true); + }); + + it('should dispatch a json-path remove operation when a map entry has changed', () => { + const valueMap = new Map(); + valueMap.set('path', ['testMap']); + valueMap.set('path2', false); + const previousValueMap = new Map(); + previousValueMap.set('path', ['testMap']); + previousValueMap.set('path2', ['testMap2']); + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], previousValueMap); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlChangeEvent, previousValue); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path2')); + }); + + it('should dispatch a json-path remove operation when current value is an empty map', () => { + const valueMap = new Map(); + const previousValueMap = new Map(); + previousValueMap.set('path', ['testMap']); + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], previousValueMap); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlChangeEvent, previousValue); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path')); + }); + + it('should dispatch a json-path remove operation when current value has a null entry and previous value is an empty map', () => { + const valueMap = new Map(); + valueMap.set('path', [null]); + const previousValueMap = new Map(); + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], previousValueMap); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlChangeEvent, previousValue); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path')); + }); + }) + +}); diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts new file mode 100644 index 0000000000..2d6b1c5477 --- /dev/null +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -0,0 +1,401 @@ +import { Injectable } from '@angular/core'; + +import { isEqual, isObject } from 'lodash'; +import { + DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + DYNAMIC_FORM_CONTROL_TYPE_GROUP, + DynamicFormArrayGroupModel, + DynamicFormControlEvent, + DynamicFormControlModel +} from '@ng-dynamic-forms/core'; + +import { isNotEmpty, isNotNull, isNotUndefined, isNull, isUndefined } from '../../../shared/empty.util'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; +import { DsDynamicInputModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { AuthorityValue } from '../../../core/integration/models/authority.value'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { DynamicQualdropModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { DynamicRelationGroupModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; + +/** + * The service handling all form section operations + */ +@Injectable() +export class SectionFormOperationsService { + + /** + * Initialize service variables + * + * @param {FormBuilderService} formBuilder + * @param {JsonPatchOperationsBuilder} operationsBuilder + */ + constructor( + private formBuilder: FormBuilderService, + private operationsBuilder: JsonPatchOperationsBuilder) { + } + + /** + * Dispatch properly method based on form operation type + * + * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @param previousValue + * the [[FormFieldPreviousValueObject]] for the specified operation + * @param hasStoredValue + * representing if field value related to the specified operation has stored value + */ + public dispatchOperationsFromEvent(pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject, + hasStoredValue: boolean): void { + switch (event.type) { + case 'remove': + this.dispatchOperationsFromRemoveEvent(pathCombiner, event, previousValue); + break; + case 'change': + this.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, hasStoredValue); + break; + default: + break; + } + } + + /** + * Return index if specified field is part of fields array + * + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @return number + * the array index is part of array, zero otherwise + */ + public getArrayIndexFromEvent(event: DynamicFormControlEvent): number { + let fieldIndex: number; + if (isNotEmpty(event)) { + if (isNull(event.context)) { + // Check whether model is part of an Array of group + if (this.isPartOfArrayOfGroup(event.model)) { + fieldIndex = (event.model.parent as any).parent.index; + } + } else { + fieldIndex = event.context.index; + } + } + + // if field index is undefined model is not part of array of fields + return isNotUndefined(fieldIndex) ? fieldIndex : 0; + } + + /** + * Check if specified model is part of array of group + * + * @param model + * the [[DynamicFormControlModel]] model + * @return boolean + * true if is part of array, false otherwise + */ + public isPartOfArrayOfGroup(model: DynamicFormControlModel): boolean { + return (isNotNull(model.parent) + && (model.parent as any).type === DYNAMIC_FORM_CONTROL_TYPE_GROUP + && (model.parent as any).parent + && (model.parent as any).parent.context + && (model.parent as any).parent.context.type === DYNAMIC_FORM_CONTROL_TYPE_ARRAY); + } + + /** + * Return a map for the values of a Qualdrop field + * + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @return Map + * the map of values + */ + public getQualdropValueMap(event: DynamicFormControlEvent): Map { + const metadataValueMap = new Map(); + + const context = this.formBuilder.isQualdropGroup(event.model) + ? (event.model.parent as DynamicFormArrayGroupModel).context + : (event.model.parent.parent as DynamicFormArrayGroupModel).context; + + context.groups.forEach((arrayModel: DynamicFormArrayGroupModel) => { + const groupModel = arrayModel.group[0] as DynamicQualdropModel; + const metadataValueList = metadataValueMap.get(groupModel.qualdropId) ? metadataValueMap.get(groupModel.qualdropId) : []; + if (groupModel.value) { + metadataValueList.push(groupModel.value); + metadataValueMap.set(groupModel.qualdropId, metadataValueList); + } + }); + + return metadataValueMap; + } + + /** + * Return the absolute path for the field interesting in the specified operation + * + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @return string + * the field path + */ + public getFieldPathFromEvent(event: DynamicFormControlEvent): string { + const fieldIndex = this.getArrayIndexFromEvent(event); + const fieldId = this.getFieldPathSegmentedFromChangeEvent(event); + return (isNotUndefined(fieldIndex)) ? fieldId + '/' + fieldIndex : fieldId; + } + + /** + * Return the absolute path for the Qualdrop field interesting in the specified operation + * + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @return string + * the field path + */ + public getQualdropItemPathFromEvent(event: DynamicFormControlEvent): string { + const fieldIndex = this.getArrayIndexFromEvent(event); + const metadataValueMap = new Map(); + let path = null; + + const context = this.formBuilder.isQualdropGroup(event.model) + ? (event.model.parent as DynamicFormArrayGroupModel).context + : (event.model.parent.parent as DynamicFormArrayGroupModel).context; + + context.groups.forEach((arrayModel: DynamicFormArrayGroupModel, index: number) => { + const groupModel = arrayModel.group[0] as DynamicQualdropModel; + const metadataValueList = metadataValueMap.get(groupModel.qualdropId) ? metadataValueMap.get(groupModel.qualdropId) : []; + if (groupModel.value) { + metadataValueList.push(groupModel.value); + metadataValueMap.set(groupModel.qualdropId, metadataValueList); + } + if (index === fieldIndex) { + path = groupModel.qualdropId + '/' + (metadataValueMap.get(groupModel.qualdropId).length - 1) + } + }); + + return path; + } + + /** + * Return the segmented path for the field interesting in the specified change operation + * + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @return string + * the field path + */ + public getFieldPathSegmentedFromChangeEvent(event: DynamicFormControlEvent): string { + let fieldId; + if (this.formBuilder.isQualdropGroup(event.model as DynamicFormControlModel)) { + fieldId = (event.model as any).qualdropId; + } else if (this.formBuilder.isQualdropGroup(event.model.parent as DynamicFormControlModel)) { + fieldId = (event.model.parent as any).qualdropId; + } else { + fieldId = this.formBuilder.getId(event.model); + } + return fieldId; + } + + /** + * Return the value of the field interesting in the specified change operation + * + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @return any + * the field value + */ + public getFieldValueFromChangeEvent(event: DynamicFormControlEvent): any { + let fieldValue; + const value = (event.model as any).value; + + if (this.formBuilder.isModelInCustomGroup(event.model)) { + fieldValue = (event.model.parent as any).value; + } else if (this.formBuilder.isRelationGroup(event.model)) { + fieldValue = (event.model as DynamicRelationGroupModel).getGroupValue(); + } else if ((event.model as any).hasLanguages) { + const language = (event.model as any).language; + if ((event.model as DsDynamicInputModel).hasAuthority) { + if (Array.isArray(value)) { + value.forEach((authority, index) => { + authority = Object.assign(new AuthorityValue(), authority, { language }); + value[index] = authority; + }); + fieldValue = value; + } else { + fieldValue = Object.assign(new AuthorityValue(), value, { language }); + } + } else { + // Language without Authority (input, textArea) + fieldValue = new FormFieldMetadataValueObject(value, language); + } + } else if (value instanceof FormFieldLanguageValueObject || value instanceof AuthorityValue || isObject(value)) { + fieldValue = value; + } else { + fieldValue = new FormFieldMetadataValueObject(value); + } + + return fieldValue; + } + + /** + * Return a map for the values of an array of field + * + * @param items + * the list of items + * @return Map + * the map of values + */ + public getValueMap(items: any[]): Map { + const metadataValueMap = new Map(); + + items.forEach((item) => { + Object.keys(item) + .forEach((key) => { + const metadataValueList = metadataValueMap.get(key) ? metadataValueMap.get(key) : []; + metadataValueList.push(item[key]); + metadataValueMap.set(key, metadataValueList); + }); + + }); + return metadataValueMap; + } + + /** + * Handle form remove operations + * + * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @param previousValue + * the [[FormFieldPreviousValueObject]] for the specified operation + */ + protected dispatchOperationsFromRemoveEvent(pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject): void { + const path = this.getFieldPathFromEvent(event); + const value = this.getFieldValueFromChangeEvent(event); + if (this.formBuilder.isQualdropGroup(event.model as DynamicFormControlModel)) { + this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue); + } else if (isNotEmpty(value)) { + this.operationsBuilder.remove(pathCombiner.getPath(path)); + } + } + + /** + * Handle form change operations + * + * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @param previousValue + * the [[FormFieldPreviousValueObject]] for the specified operation + * @param hasStoredValue + * representing if field value related to the specified operation has stored value + */ + protected dispatchOperationsFromChangeEvent(pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject, + hasStoredValue: boolean): void { + const path = this.getFieldPathFromEvent(event); + const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); + const value = this.getFieldValueFromChangeEvent(event); + // Detect which operation must be dispatched + if (this.formBuilder.isQualdropGroup(event.model.parent as DynamicFormControlModel)) { + // It's a qualdrup model + this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue); + } else if (this.formBuilder.isRelationGroup(event.model)) { + // It's a relation model + this.dispatchOperationsFromMap(this.getValueMap(value), pathCombiner, event, previousValue); + } else if (this.formBuilder.hasArrayGroupValue(event.model)) { + // Model has as value an array, so dispatch an add operation with entire block of values + this.operationsBuilder.add( + pathCombiner.getPath(segmentedPath), + value, true); + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || hasStoredValue) { + // Here model has a previous value changed or stored in the server + if (!value.hasValue()) { + // New value is empty, so dispatch a remove operation + if (this.getArrayIndexFromEvent(event) === 0) { + this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); + } else { + this.operationsBuilder.remove(pathCombiner.getPath(path)); + } + } else { + // New value is not equal from the previous one, so dispatch a replace operation + this.operationsBuilder.replace( + pathCombiner.getPath(path), + value); + } + previousValue.delete(); + } else if (value.hasValue()) { + // Here model has no previous value but a new one + if (isUndefined(this.getArrayIndexFromEvent(event)) + || this.getArrayIndexFromEvent(event) === 0) { + // Model is single field or is part of an array model but is the first item, + // so dispatch an add operation that initialize the values of a specific metadata + this.operationsBuilder.add( + pathCombiner.getPath(segmentedPath), + value, true); + } else { + // Model is part of an array model but is not the first item, + // so dispatch an add operation that add a value to an existent metadata + this.operationsBuilder.add( + pathCombiner.getPath(path), + value); + } + } + } + + /** + * Handle form operations interesting a field with a map as value + * + * @param valueMap + * map of values + * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation + * @param event + * the [[DynamicFormControlEvent]] for the specified operation + * @param previousValue + * the [[FormFieldPreviousValueObject]] for the specified operation + */ + protected dispatchOperationsFromMap(valueMap: Map, + pathCombiner: JsonPatchOperationPathCombiner, + event: DynamicFormControlEvent, + previousValue: FormFieldPreviousValueObject): void { + const currentValueMap = valueMap; + if (event.type === 'remove') { + const path = this.getQualdropItemPathFromEvent(event); + this.operationsBuilder.remove(pathCombiner.getPath(path)); + } else { + if (previousValue.isPathEqual(this.formBuilder.getPath(event.model))) { + previousValue.value.forEach((entry, index) => { + const currentValue = currentValueMap.get(index); + if (currentValue) { + if (!isEqual(entry, currentValue)) { + this.operationsBuilder.add(pathCombiner.getPath(index), currentValue, true); + } + currentValueMap.delete(index); + } else if (!currentValue) { + this.operationsBuilder.remove(pathCombiner.getPath(index)); + } + }); + } + currentValueMap.forEach((entry: any[], index) => { + if (entry.length === 1 && isNull(entry[0])) { + // The last item of the group has been deleted so make a remove op + this.operationsBuilder.remove(pathCombiner.getPath(index)); + } else { + this.operationsBuilder.add(pathCombiner.getPath(index), entry, true); + } + }); + } + + previousValue.delete(); + } +} diff --git a/src/app/submission/sections/form/section-form.component.html b/src/app/submission/sections/form/section-form.component.html new file mode 100644 index 0000000000..166e52675b --- /dev/null +++ b/src/app/submission/sections/form/section-form.component.html @@ -0,0 +1,9 @@ + + diff --git a/src/app/submission/sections/form/section-form.component.scss b/src/app/submission/sections/form/section-form.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/submission/sections/form/section-form.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts new file mode 100644 index 0000000000..477d42a0a1 --- /dev/null +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -0,0 +1,473 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { createTestComponent } from '../../../shared/testing/utils'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { getMockTranslateService } from '../../../shared/mocks/mock-translate.service'; +import { SectionsService } from '../sections.service'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service-stub'; +import { SubmissionSectionformComponent } from './section-form.component'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { getMockFormBuilderService } from '../../../shared/mocks/mock-form-builder-service'; +import { getMockFormOperationsService } from '../../../shared/mocks/mock-form-operations-service'; +import { SectionFormOperationsService } from './section-form-operations.service'; +import { getMockFormService } from '../../../shared/mocks/mock-form-service'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionFormsConfigService } from '../../../core/config/submission-forms-config.service'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { MOCK_SUBMISSION_CONFIG } from '../../../shared/testing/mock-submission-config'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsType } from '../sections-type'; +import { + mockSubmissionCollectionId, + mockSubmissionId, + mockUploadResponse1ParsedErrors +} from '../../../shared/mocks/mock-submission'; +import { BrowserModule } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormComponent } from '../../../shared/form/form.component'; +import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; +import { FormRowModel } from '../../../core/config/models/config-submission-forms.model'; +import { ConfigData } from '../../../core/config/config-data'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { DynamicRowGroupModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; +import { DsDynamicInputModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { SubmissionSectionError } from '../../objects/submission-objects.reducer'; +import { DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; + +function getMockSubmissionFormsConfigService(): SubmissionFormsConfigService { + return jasmine.createSpyObj('FormOperationsService', { + getConfigAll: jasmine.createSpy('getConfigAll'), + getConfigByHref: jasmine.createSpy('getConfigByHref'), + getConfigByName: jasmine.createSpy('getConfigByName'), + getConfigBySearch: jasmine.createSpy('getConfigBySearch') + }); +} + +const sectionObject: SectionDataObject = { + config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/traditionalpageone', + mandatory: true, + data: {}, + errors: [], + header: 'submit.progressbar.describe.stepone', + id: 'traditionalpageone', + sectionType: SectionsType.SubmissionForm +}; + +const testFormConfiguration = { + name: 'testFormConfiguration', + rows: [ + { + fields: [ + { + input: { + type: 'onebox' + }, + label: 'Title', + mandatory: 'true', + repeatable: false, + hints: ' Enter Title.', + selectableMetadata: [ + { + metadata: 'dc.title' + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel, + { + fields: [ + { + input: { + type: 'onebox' + }, + label: 'Author', + mandatory: 'false', + repeatable: false, + hints: ' Enter Author.', + selectableMetadata: [ + { + metadata: 'dc.contributor' + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel, + ], + self: 'testFormConfiguration.url', + type: 'submissionform', + _links: { + self: 'testFormConfiguration.url' + } +} as any; + +const testFormModel = [ + new DynamicRowGroupModel({ + id: 'df-row-group-config-1', + group: [new DsDynamicInputModel({ id: 'dc.title' })], + }), + new DynamicRowGroupModel({ + id: 'df-row-group-config-2', + group: [new DsDynamicInputModel({ id: 'dc.contributor' })], + }) +]; + +const dynamicFormControlEvent: DynamicFormControlEvent = { + $event: new Event('change'), + context: null, + control: null, + group: testFormModel[0] as any, + model: testFormModel[0].group[0], + type: DynamicFormControlEventType.Change +}; + +describe('SubmissionSectionformComponent test suite', () => { + + let comp: SubmissionSectionformComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let sectionsServiceStub: SectionsServiceStub; + let notificationsServiceStub: NotificationsServiceStub; + let formService: any; + let formConfigService: any; + let formOperationsService: any; + let formBuilderService: any; + let translateService: any; + + const envConfig: GlobalConfig = MOCK_SUBMISSION_CONFIG; + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const parsedSectionErrors: any = mockUploadResponse1ParsedErrors.traditionalpageone; + const formConfigData = new ConfigData(new PageInfo(), testFormConfiguration); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + declarations: [ + FormComponent, + SubmissionSectionformComponent, + TestComponent + ], + providers: [ + { provide: FormBuilderService, useValue: getMockFormBuilderService() }, + { provide: SectionFormOperationsService, useValue: getMockFormOperationsService() }, + { provide: FormService, useValue: getMockFormService() }, + { provide: SubmissionFormsConfigService, useValue: getMockSubmissionFormsConfigService() }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: GLOBAL_CONFIG, useValue: envConfig }, + { provide: 'collectionIdProvider', useValue: collectionId }, + { provide: 'sectionDataProvider', useValue: sectionObject }, + { provide: 'submissionIdProvider', useValue: submissionId }, + ChangeDetectorRef, + SubmissionSectionformComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + 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 SubmissionSectionformComponent', inject([SubmissionSectionformComponent], (app: SubmissionSectionformComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionformComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + sectionsServiceStub = TestBed.get(SectionsService); + formService = TestBed.get(FormService); + formConfigService = TestBed.get(SubmissionFormsConfigService); + formBuilderService = TestBed.get(FormBuilderService); + formOperationsService = TestBed.get(SectionFormOperationsService); + translateService = TestBed.get(TranslateService); + notificationsServiceStub = TestBed.get(NotificationsService); + + translateService.get.and.returnValue(observableOf('test')); + compAsAny.pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionObject.id); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should init section properly', () => { + const sectionData = {}; + formService.isValid.and.returnValue(observableOf(true)); + formConfigService.getConfigByHref.and.returnValue(observableOf(formConfigData)); + sectionsServiceStub.getSectionData.and.returnValue(observableOf(sectionData)); + spyOn(comp, 'initForm'); + spyOn(comp, 'subscriptions'); + + comp.onSectionInit(); + fixture.detectChanges(); + + expect(compAsAny.formConfig).toEqual(formConfigData.payload); + expect(comp.sectionData.errors).toEqual([]); + expect(comp.sectionData.data).toEqual(sectionData); + expect(comp.isLoading).toBeFalsy(); + expect(comp.initForm).toHaveBeenCalledWith(sectionData); + expect(comp.subscriptions).toHaveBeenCalled(); + + }); + + it('should init form model properly', () => { + formBuilderService.modelFromConfiguration.and.returnValue(testFormModel); + const sectionData = {}; + + comp.initForm(sectionData); + + expect(comp.formModel).toEqual(testFormModel); + + }); + + it('should set a section Error when init form model fails', () => { + formBuilderService.modelFromConfiguration.and.throwError('test'); + translateService.instant.and.returnValue('test'); + const sectionData = {}; + const sectionError: SubmissionSectionError = { + message: 'test' + 'Error: test', + path: '/sections/' + sectionObject.id + }; + + comp.initForm(sectionData); + + expect(comp.formModel).toBeUndefined(); + expect(sectionsServiceStub.setSectionError).toHaveBeenCalledWith(submissionId, sectionObject.id, sectionError); + + }); + + it('should return true when has Metadata Enrichment', () => { + const newSectionData = { + 'dc.title': [new FormFieldMetadataValueObject('test')] + }; + compAsAny.formData = {}; + + expect(comp.hasMetadataEnrichment(newSectionData)).toBeTruthy(); + }); + + it('should return false when has not Metadata Enrichment', () => { + const newSectionData = { + 'dc.title': [new FormFieldMetadataValueObject('test')] + }; + compAsAny.formData = newSectionData; + + expect(comp.hasMetadataEnrichment(newSectionData)).toBeFalsy(); + }); + + it('should update form properly', () => { + spyOn(comp, 'initForm'); + spyOn(comp, 'checksForErrors'); + const sectionData: any = { + 'dc.title': [new FormFieldMetadataValueObject('test')] + }; + const sectionError = []; + comp.sectionData.data = {}; + comp.sectionData.errors = []; + compAsAny.formData = {}; + + comp.updateForm(sectionData, sectionError); + + expect(comp.isUpdating).toBeFalsy(); + expect(comp.initForm).toHaveBeenCalled(); + expect(comp.checksForErrors).toHaveBeenCalled(); + expect(notificationsServiceStub.info).toHaveBeenCalled(); + expect(comp.sectionData.data).toEqual(sectionData); + + }); + + it('should update form error properly', () => { + spyOn(comp, 'initForm'); + spyOn(comp, 'checksForErrors'); + const sectionData: any = { + 'dc.title': [new FormFieldMetadataValueObject('test')] + }; + comp.sectionData.data = {}; + comp.sectionData.errors = []; + compAsAny.formData = sectionData; + + comp.updateForm(sectionData, parsedSectionErrors); + + expect(comp.initForm).not.toHaveBeenCalled(); + expect(comp.checksForErrors).toHaveBeenCalled(); + expect(comp.sectionData.data).toEqual(sectionData); + }); + + it('should update form error properly', () => { + spyOn(comp, 'initForm'); + spyOn(comp, 'checksForErrors'); + const sectionData: any = {}; + + comp.updateForm(sectionData, parsedSectionErrors); + + expect(comp.initForm).not.toHaveBeenCalled(); + expect(comp.checksForErrors).toHaveBeenCalled(); + + }); + + it('should check for error', () => { + comp.isUpdating = false; + comp.formId = 'test'; + comp.sectionData.errors = []; + + comp.checksForErrors(parsedSectionErrors); + + expect(sectionsServiceStub.checkSectionErrors).toHaveBeenCalledWith( + submissionId, + sectionObject.id, + 'test', + parsedSectionErrors, + [] + ); + expect(comp.sectionData.errors).toEqual(parsedSectionErrors); + }); + + it('should subscribe to state properly', () => { + spyOn(comp, 'updateForm'); + const formData = { + 'dc.title': [new FormFieldMetadataValueObject('test')] + }; + const sectionData: any = { + 'dc.title': [new FormFieldMetadataValueObject('test')] + }; + const sectionState = { + data: sectionData, + errors: parsedSectionErrors + }; + + formService.getFormData.and.returnValue(observableOf(formData)); + sectionsServiceStub.getSectionState.and.returnValue(observableOf(sectionState)); + + comp.subscriptions(); + + expect(compAsAny.subs.length).toBe(2); + expect(compAsAny.formData).toEqual(formData); + expect(comp.updateForm).toHaveBeenCalledWith(sectionState.data, sectionState.errors); + + }); + + it('should call dispatchOperationsFromEvent on form change', () => { + spyOn(comp, 'hasStoredValue').and.returnValue(false); + formOperationsService.getFieldPathSegmentedFromChangeEvent.and.returnValue('path'); + formOperationsService.getFieldValueFromChangeEvent.and.returnValue('test'); + + comp.onChange(dynamicFormControlEvent); + + expect(formOperationsService.dispatchOperationsFromEvent).toHaveBeenCalled(); + expect(formOperationsService.getFieldPathSegmentedFromChangeEvent).toHaveBeenCalledWith(dynamicFormControlEvent); + expect(formOperationsService.getFieldValueFromChangeEvent).toHaveBeenCalledWith(dynamicFormControlEvent); + expect(submissionServiceStub.dispatchSave).not.toHaveBeenCalledWith(submissionId); + + }); + + it('should call dispatchSave on form change when metadata is in submission autosave configuration', () => { + spyOn(comp, 'hasStoredValue').and.returnValue(false); + formOperationsService.getFieldPathSegmentedFromChangeEvent.and.returnValue('dc.title'); + formOperationsService.getFieldValueFromChangeEvent.and.returnValue('test'); + + comp.onChange(dynamicFormControlEvent); + + expect(formOperationsService.dispatchOperationsFromEvent).toHaveBeenCalled(); + expect(formOperationsService.getFieldPathSegmentedFromChangeEvent).toHaveBeenCalledWith(dynamicFormControlEvent); + expect(formOperationsService.getFieldValueFromChangeEvent).toHaveBeenCalledWith(dynamicFormControlEvent); + expect(submissionServiceStub.dispatchSave).toHaveBeenCalledWith(submissionId); + + }); + + it('should set previousValue on form focus event', () => { + formBuilderService.hasMappedGroupValue.and.returnValue(false); + formOperationsService.getFieldValueFromChangeEvent.and.returnValue('test'); + + comp.onFocus(dynamicFormControlEvent); + + expect(compAsAny.previousValue.path).toEqual(['test', 'path']); + expect(compAsAny.previousValue.value).toBe('test'); + + formBuilderService.hasMappedGroupValue.and.returnValue(true); + formOperationsService.getQualdropValueMap.and.returnValue('qualdrop'); + + comp.onFocus(dynamicFormControlEvent); + expect(compAsAny.previousValue.path).toEqual(['test', 'path']); + expect(compAsAny.previousValue.value).toBe('qualdrop'); + + formBuilderService.hasMappedGroupValue.and.returnValue(false); + formOperationsService.getFieldValueFromChangeEvent.and.returnValue(new FormFieldMetadataValueObject('form value test')); + + comp.onFocus(dynamicFormControlEvent); + expect(compAsAny.previousValue.path).toEqual(['test', 'path']); + expect(compAsAny.previousValue.value).toEqual(new FormFieldMetadataValueObject('form value test')); + }); + + it('should call dispatchOperationsFromEvent on form remove event', () => { + spyOn(comp, 'hasStoredValue').and.returnValue(false); + + comp.onRemove(dynamicFormControlEvent); + + expect(formOperationsService.dispatchOperationsFromEvent).toHaveBeenCalled(); + + }); + + it('should check if has stored value in the section state', () => { + comp.sectionData.data = { + 'dc.title': [new FormFieldMetadataValueObject('test')] + } as any; + + expect(comp.hasStoredValue('dc.title', 0)).toBeTruthy(); + expect(comp.hasStoredValue('dc.title', 1)).toBeFalsy(); + expect(comp.hasStoredValue('title', 0)).toBeFalsy(); + + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts new file mode 100644 index 0000000000..ef817a7568 --- /dev/null +++ b/src/app/submission/sections/form/section-form.component.ts @@ -0,0 +1,373 @@ +import { ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; +import { DynamicFormControlEvent, DynamicFormControlModel } from '@ng-dynamic-forms/core'; + +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, take, tap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { isEqual } from 'lodash'; + +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SubmissionFormsConfigService } from '../../../core/config/submission-forms-config.service'; +import { hasValue, isNotEmpty, isUndefined } from '../../../shared/empty.util'; +import { ConfigData } from '../../../core/config/config-data'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; +import { SubmissionSectionError, SubmissionSectionObject } from '../../objects/submission-objects.reducer'; +import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { GlobalConfig } from '../../../../config/global-config.interface'; +import { SectionDataObject } from '../models/section-data.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionsType } from '../sections-type'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from './section-form-operations.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SectionsService } from '../sections.service'; +import { difference } from '../../../shared/object.util'; +import { WorkspaceitemSectionFormObject } from '../../../core/submission/models/workspaceitem-section-form.model'; + +/** + * This component represents a section that contains a Form. + */ +@Component({ + selector: 'ds-submission-section-form', + styleUrls: ['./section-form.component.scss'], + templateUrl: './section-form.component.html', +}) +@renderSectionFor(SectionsType.SubmissionForm) +export class SubmissionSectionformComponent extends SectionModelComponent { + + /** + * The form id + * @type {string} + */ + public formId: string; + + /** + * The form model + * @type {DynamicFormControlModel[]} + */ + public formModel: DynamicFormControlModel[]; + + /** + * A boolean representing if this section is updating + * @type {boolean} + */ + public isUpdating = false; + + /** + * A boolean representing if this section is loading + * @type {boolean} + */ + public isLoading = true; + + /** + * The form config + * @type {SubmissionFormsModel} + */ + protected formConfig: SubmissionFormsModel; + + /** + * The form data + * @type {any} + */ + protected formData: any = Object.create({}); + + /** + * The [JsonPatchOperationPathCombiner] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + + /** + * The [FormFieldPreviousValueObject] object + * @type {FormFieldPreviousValueObject} + */ + protected previousValue: FormFieldPreviousValueObject = new FormFieldPreviousValueObject(); + + /** + * The list of Subscription + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * The FormComponent reference + */ + @ViewChild('formRef') private formRef: FormComponent; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} cdr + * @param {FormBuilderService} formBuilderService + * @param {SectionFormOperationsService} formOperationsService + * @param {FormService} formService + * @param {SubmissionFormsConfigService} formConfigService + * @param {NotificationsService} notificationsService + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {TranslateService} translate + * @param {GlobalConfig} EnvConfig + * @param {string} injectedCollectionId + * @param {SectionDataObject} injectedSectionData + * @param {string} injectedSubmissionId + */ + constructor(protected cdr: ChangeDetectorRef, + protected formBuilderService: FormBuilderService, + protected formOperationsService: SectionFormOperationsService, + protected formService: FormService, + protected formConfigService: SubmissionFormsConfigService, + protected notificationsService: NotificationsService, + protected sectionService: SectionsService, + protected submissionService: SubmissionService, + protected translate: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + /** + * Initialize all instance variables and retrieve form configuration + */ + onSectionInit() { + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + this.formId = this.formService.getUniqueId(this.sectionData.id); + + this.formConfigService.getConfigByHref(this.sectionData.config).pipe( + map((configData: ConfigData) => configData.payload), + tap((config: SubmissionFormsModel) => this.formConfig = config), + flatMap(() => this.sectionService.getSectionData(this.submissionId, this.sectionData.id)), + take(1)) + .subscribe((sectionData: WorkspaceitemSectionFormObject) => { + if (isUndefined(this.formModel)) { + this.sectionData.errors = []; + // Is the first loading so init form + this.initForm(sectionData); + this.sectionData.data = sectionData; + this.subscriptions(); + this.isLoading = false; + this.cdr.detectChanges(); + } + }) + } + + /** + * Unsubscribe from all subscriptions + */ + onSectionDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + /** + * Get section status + * + * @return Observable + * the section status + */ + protected getSectionStatus(): Observable { + return this.formService.isValid(this.formId); + } + + /** + * Check if the section data has been enriched by the server + * + * @param sectionData + * the section data retrieved from the server + */ + hasMetadataEnrichment(sectionData: WorkspaceitemSectionFormObject): boolean { + const diffResult = []; + + // compare current form data state with section data retrieved from store + const diffObj = difference(sectionData, this.formData); + + // iterate over differences to check whether they are actually different + Object.keys(diffObj) + .forEach((key) => { + diffObj[key].forEach((value) => { + if (value.hasOwnProperty('value')) { + diffResult.push(value); + } + }); + }); + return isNotEmpty(diffResult); + } + + /** + * Initialize form model + * + * @param sectionData + * the section data retrieved from the server + */ + initForm(sectionData: WorkspaceitemSectionFormObject): void { + try { + this.formModel = this.formBuilderService.modelFromConfiguration( + this.formConfig, + this.collectionId, + sectionData, + this.submissionService.getSubmissionScope()); + } catch (e) { + const msg: string = this.translate.instant('error.submission.sections.init-form-error') + e.toString(); + const sectionError: SubmissionSectionError = { + message: msg, + path: '/sections/' + this.sectionData.id + }; + this.sectionService.setSectionError(this.submissionId, this.sectionData.id, sectionError); + } + } + + /** + * Update form model + * + * @param sectionData + * the section data retrieved from the server + * @param errors + * the section errors retrieved from the server + */ + updateForm(sectionData: WorkspaceitemSectionFormObject, errors: SubmissionSectionError[]): void { + + if (isNotEmpty(sectionData) && !isEqual(sectionData, this.sectionData.data)) { + this.sectionData.data = sectionData; + if (this.hasMetadataEnrichment(sectionData)) { + const msg = this.translate.instant( + 'submission.sections.general.metadata-extracted', + { sectionId: this.sectionData.id }); + this.notificationsService.info(null, msg, null, true); + this.isUpdating = true; + this.formModel = null; + this.cdr.detectChanges(); + this.initForm(sectionData); + this.checksForErrors(errors); + this.isUpdating = false; + this.cdr.detectChanges(); + } else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errors)) { + this.checksForErrors(errors); + } + } else if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errors)) { + this.checksForErrors(errors); + } + + } + + /** + * Check if there are form validation error retrieved from server + * + * @param errors + * the section errors retrieved from the server + */ + checksForErrors(errors: SubmissionSectionError[]): void { + this.formService.isFormInitialized(this.formId).pipe( + find((status: boolean) => status === true && !this.isUpdating)) + .subscribe(() => { + this.sectionService.checkSectionErrors(this.submissionId, this.sectionData.id, this.formId, errors, this.sectionData.errors); + this.sectionData.errors = errors; + this.cdr.detectChanges(); + }); + } + + /** + * Initialize all subscriptions + */ + subscriptions(): void { + this.subs.push( + /** + * Subscribe to form's data + */ + this.formService.getFormData(this.formId).pipe( + distinctUntilChanged()) + .subscribe((formData) => { + this.formData = formData; + }), + + /** + * Subscribe to section state + */ + this.sectionService.getSectionState(this.submissionId, this.sectionData.id).pipe( + filter((sectionState: SubmissionSectionObject) => { + return isNotEmpty(sectionState) && (isNotEmpty(sectionState.data) || isNotEmpty(sectionState.errors)) + }), + distinctUntilChanged()) + .subscribe((sectionState: SubmissionSectionObject) => { + this.updateForm(sectionState.data as WorkspaceitemSectionFormObject, sectionState.errors); + }) + ) + } + + /** + * Method called when a form dfChange event is fired. + * Dispatch form operations based on changes. + * + * @param event + * the [[DynamicFormControlEvent]] emitted + */ + onChange(event: DynamicFormControlEvent): void { + this.formOperationsService.dispatchOperationsFromEvent( + this.pathCombiner, + event, + this.previousValue, + this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event))); + const metadata = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event); + const value = this.formOperationsService.getFieldValueFromChangeEvent(event); + + if (this.EnvConfig.submission.autosave.metadata.indexOf(metadata) !== -1 && isNotEmpty(value)) { + this.submissionService.dispatchSave(this.submissionId); + } + } + + /** + * Method called when a form dfFocus event is fired. + * Initialize [FormFieldPreviousValueObject] instance. + * + * @param event + * the [[DynamicFormControlEvent]] emitted + */ + onFocus(event: DynamicFormControlEvent): void { + const value = this.formOperationsService.getFieldValueFromChangeEvent(event); + const path = this.formBuilderService.getPath(event.model); + if (this.formBuilderService.hasMappedGroupValue(event.model)) { + this.previousValue.path = path; + this.previousValue.value = this.formOperationsService.getQualdropValueMap(event); + } else if (isNotEmpty(value) && ((typeof value === 'object' && isNotEmpty(value.value)) || (typeof value === 'string'))) { + this.previousValue.path = path; + this.previousValue.value = value; + } + } + + /** + * Method called when a form remove event is fired. + * Dispatch form operations based on changes. + * + * @param event + * the [[DynamicFormControlEvent]] emitted + */ + onRemove(event: DynamicFormControlEvent): void { + this.formOperationsService.dispatchOperationsFromEvent( + this.pathCombiner, + event, + this.previousValue, + this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event))); + } + + /** + * Check if the specified form field has already a value stored + * + * @param fieldId + * the section data retrieved from the serverù + * @param index + * the section data retrieved from the server + */ + hasStoredValue(fieldId, index): boolean { + if (isNotEmpty(this.sectionData.data)) { + return this.sectionData.data.hasOwnProperty(fieldId) && isNotEmpty(this.sectionData.data[fieldId][index]); + } else { + return false; + } + } +} diff --git a/src/app/submission/sections/license/section-license.component.html b/src/app/submission/sections/license/section-license.component.html new file mode 100644 index 0000000000..7b0faad5e3 --- /dev/null +++ b/src/app/submission/sections/license/section-license.component.html @@ -0,0 +1,8 @@ +{{ licenseText$ | async }} +

    + diff --git a/src/app/submission/sections/license/section-license.component.scss b/src/app/submission/sections/license/section-license.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/license/section-license.component.spec.ts b/src/app/submission/sections/license/section-license.component.spec.ts new file mode 100644 index 0000000000..6d8f82c0f8 --- /dev/null +++ b/src/app/submission/sections/license/section-license.component.spec.ts @@ -0,0 +1,338 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { DynamicCheckboxModel, DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; + +import { createTestComponent } from '../../../shared/testing/utils'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { SectionsService } from '../sections.service'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service-stub'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { getMockFormOperationsService } from '../../../shared/mocks/mock-form-operations-service'; +import { getMockFormService } from '../../../shared/mocks/mock-form-service'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionFormsConfigService } from '../../../core/config/submission-forms-config.service'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsType } from '../sections-type'; +import { + mockLicenseParsedErrors, + mockSubmissionCollectionId, + mockSubmissionId +} from '../../../shared/mocks/mock-submission'; +import { FormComponent } from '../../../shared/form/form.component'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { SubmissionSectionLicenseComponent } from './section-license.component'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { License } from '../../../core/shared/license.model'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { cold } from 'jasmine-marbles'; + +function getMockSubmissionFormsConfigService(): SubmissionFormsConfigService { + return jasmine.createSpyObj('FormOperationsService', { + getConfigAll: jasmine.createSpy('getConfigAll'), + getConfigByHref: jasmine.createSpy('getConfigByHref'), + getConfigByName: jasmine.createSpy('getConfigByName'), + getConfigBySearch: jasmine.createSpy('getConfigBySearch') + }); +} + +function getMockCollectionDataService(): CollectionDataService { + return jasmine.createSpyObj('CollectionDataService', { + findById: jasmine.createSpy('findById'), + findByHref: jasmine.createSpy('findByHref') + }); +} + +const sectionObject: SectionDataObject = { + config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/license', + mandatory: true, + data: { + url: null, + acceptanceDate: null, + granted: false + }, + errors: [], + header: 'submit.progressbar.describe.license', + id: 'license', + sectionType: SectionsType.License +}; + +const dynamicFormControlEvent: DynamicFormControlEvent = { + $event: new Event('change'), + context: null, + control: null, + group: null, + model: null, + type: DynamicFormControlEventType.Change +}; + +describe('SubmissionSectionLicenseComponent test suite', () => { + + let comp: SubmissionSectionLicenseComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let sectionsServiceStub: SectionsServiceStub; + let formService: any; + let formOperationsService: any; + let formBuilderService: any; + let collectionDataService: any; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionObject.id); + const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), + }); + + const licenseText = 'License text'; + const mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + license: observableOf(new RemoteData(false, false, true, + undefined, Object.assign(new License(), { text: licenseText }))) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + declarations: [ + FormComponent, + SubmissionSectionLicenseComponent, + TestComponent + ], + providers: [ + { provide: CollectionDataService, useValue: getMockCollectionDataService() }, + { provide: SectionFormOperationsService, useValue: getMockFormOperationsService() }, + { provide: FormService, useValue: getMockFormService() }, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + { provide: SubmissionFormsConfigService, useValue: getMockSubmissionFormsConfigService() }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: 'collectionIdProvider', useValue: collectionId }, + { provide: 'sectionDataProvider', useValue: sectionObject }, + { provide: 'submissionIdProvider', useValue: submissionId }, + ChangeDetectorRef, + FormBuilderService, + SubmissionSectionLicenseComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + 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 SubmissionSectionLicenseComponent', inject([SubmissionSectionLicenseComponent], (app: SubmissionSectionLicenseComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionLicenseComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + sectionsServiceStub = TestBed.get(SectionsService); + formService = TestBed.get(FormService); + formBuilderService = TestBed.get(FormBuilderService); + formOperationsService = TestBed.get(SectionFormOperationsService); + collectionDataService = TestBed.get(CollectionDataService); + + compAsAny.pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionObject.id); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should init section properly', () => { + collectionDataService.findById.and.returnValue(observableOf(new RemoteData(false, false, true, + undefined, mockCollection))); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + spyOn(compAsAny, 'getSectionStatus'); + + comp.onSectionInit(); + + const model = formBuilderService.findById('granted', comp.formModel); + + expect(compAsAny.subs.length).toBe(2); + expect(comp.formModel).toBeDefined(); + expect(model.value).toBeFalsy(); + expect(comp.licenseText$).toBeObservable(cold('(ab|)', { + a: '', + b: licenseText + })); + }); + + it('should set checkbox value to true', () => { + comp.sectionData.data = { + url: 'url', + acceptanceDate: Date.now(), + granted: true + } as any; + + collectionDataService.findById.and.returnValue(observableOf(new RemoteData(false, false, true, + undefined, mockCollection))); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + spyOn(compAsAny, 'getSectionStatus'); + + comp.onSectionInit(); + + const model = formBuilderService.findById('granted', comp.formModel); + + expect(compAsAny.subs.length).toBe(2); + expect(comp.formModel).toBeDefined(); + expect(model.value).toBeTruthy(); + expect(comp.licenseText$).toBeObservable(cold('(ab|)', { + a: '', + b: licenseText + })); + }); + + it('should set section errors properly', () => { + collectionDataService.findById.and.returnValue(observableOf(new RemoteData(false, false, true, + undefined, mockCollection))); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf(mockLicenseParsedErrors.license)); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + + comp.onSectionInit(); + const expectedErrors = mockLicenseParsedErrors.license; + + expect(sectionsServiceStub.checkSectionErrors).toHaveBeenCalled(); + expect(comp.sectionData.errors).toEqual(expectedErrors); + + }); + + it('should remove any section\'s errors when checkbox is selected', () => { + comp.sectionData.data = { + url: 'url', + acceptanceDate: Date.now(), + granted: true + } as any; + + collectionDataService.findById.and.returnValue(observableOf(new RemoteData(false, false, true, + undefined, mockCollection))); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf(mockLicenseParsedErrors.license)); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + + comp.onSectionInit(); + + expect(sectionsServiceStub.dispatchRemoveSectionErrors).toHaveBeenCalled(); + + }); + + it('should have status true when checkbox is selected', () => { + + collectionDataService.findById.and.returnValue(observableOf(new RemoteData(false, false, true, + undefined, mockCollection))); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + + fixture.detectChanges(); + const model = formBuilderService.findById('granted', comp.formModel); + + (model as DynamicCheckboxModel).valueUpdates.next(true); + + compAsAny.getSectionStatus().subscribe((status) => { + expect(status).toBeTruthy(); + }); + }); + + it('should have status false when checkbox is not selected', () => { + + collectionDataService.findById.and.returnValue(observableOf(new RemoteData(false, false, true, + undefined, mockCollection))); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + + fixture.detectChanges(); + const model = formBuilderService.findById('granted', comp.formModel); + + compAsAny.getSectionStatus().subscribe((status) => { + expect(status).toBeFalsy(); + }); + + (model as DynamicCheckboxModel).valueUpdates.next(false); + }); + + it('should dispatch a json-path add operation when checkbox is selected', () => { + const event = dynamicFormControlEvent; + formOperationsService.getFieldPathSegmentedFromChangeEvent.and.returnValue('granted'); + formOperationsService.getFieldValueFromChangeEvent.and.returnValue(new FormFieldMetadataValueObject(true)); + + comp.onChange(event); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('granted'), 'true', false, true); + expect(sectionsServiceStub.dispatchRemoveSectionErrors).toHaveBeenCalled(); + }); + + it('should dispatch a json-path remove operation when checkbox is not selected', () => { + const event = dynamicFormControlEvent; + formOperationsService.getFieldPathSegmentedFromChangeEvent.and.returnValue('granted'); + formOperationsService.getFieldValueFromChangeEvent.and.returnValue(null); + + comp.onChange(event); + + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('granted')); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/submission/sections/license/section-license.component.ts b/src/app/submission/sections/license/section-license.component.ts new file mode 100644 index 0000000000..940460c83d --- /dev/null +++ b/src/app/submission/sections/license/section-license.component.ts @@ -0,0 +1,224 @@ +import { ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; + +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, startWith, take } from 'rxjs/operators'; +import { + DynamicCheckboxModel, + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicFormLayout +} from '@ng-dynamic-forms/core'; + +import { SectionModelComponent } from '../models/section.model'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../shared/empty.util'; +import { License } from '../../../core/shared/license.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Collection } from '../../../core/shared/collection.model'; +import { SECTION_LICENSE_FORM_LAYOUT, SECTION_LICENSE_FORM_MODEL } from './section-license.model'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormService } from '../../../shared/form/form.service'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { SectionsType } from '../sections-type'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { WorkspaceitemSectionLicenseObject } from '../../../core/submission/models/workspaceitem-section-license.model'; +import { SubmissionService } from '../../submission.service'; +import { SectionsService } from '../sections.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { FormComponent } from '../../../shared/form/form.component'; + +/** + * This component represents a section that contains the submission license form. + */ +@Component({ + selector: 'ds-submission-section-license', + styleUrls: ['./section-license.component.scss'], + templateUrl: './section-license.component.html', +}) +@renderSectionFor(SectionsType.License) +export class SubmissionSectionLicenseComponent extends SectionModelComponent { + + /** + * The form id + * @type {string} + */ + public formId: string; + + /** + * The form model + * @type {DynamicFormControlModel[]} + */ + public formModel: DynamicFormControlModel[]; + + /** + * The [[DynamicFormLayout]] object + * @type {DynamicFormLayout} + */ + public formLayout: DynamicFormLayout = SECTION_LICENSE_FORM_LAYOUT; + + /** + * A boolean representing if to show form submit and cancel buttons + * @type {boolean} + */ + public displaySubmit = false; + + /** + * The submission license text + * @type {Array} + */ + public licenseText$: Observable; + + /** + * The [[JsonPatchOperationPathCombiner]] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * The FormComponent reference + */ + @ViewChild('formRef') private formRef: FormComponent; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} changeDetectorRef + * @param {CollectionDataService} collectionDataService + * @param {FormBuilderService} formBuilderService + * @param {SectionFormOperationsService} formOperationsService + * @param {FormService} formService + * @param {JsonPatchOperationsBuilder} operationsBuilder + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {string} injectedCollectionId + * @param {SectionDataObject} injectedSectionData + * @param {string} injectedSubmissionId + */ + constructor(protected changeDetectorRef: ChangeDetectorRef, + protected collectionDataService: CollectionDataService, + protected formBuilderService: FormBuilderService, + protected formOperationsService: SectionFormOperationsService, + protected formService: FormService, + protected operationsBuilder: JsonPatchOperationsBuilder, + protected sectionService: SectionsService, + protected submissionService: SubmissionService, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + /** + * Initialize all instance variables and retrieve submission license + */ + onSectionInit() { + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + this.formId = this.formService.getUniqueId(this.sectionData.id); + this.formModel = this.formBuilderService.fromJSON(SECTION_LICENSE_FORM_MODEL); + const model = this.formBuilderService.findById('granted', this.formModel); + + // Retrieve license accepted status + if ((this.sectionData.data as WorkspaceitemSectionLicenseObject).granted) { + (model as DynamicCheckboxModel).valueUpdates.next(true); + } else { + (model as DynamicCheckboxModel).valueUpdates.next(false); + } + + this.licenseText$ = this.collectionDataService.findById(this.collectionId).pipe( + filter((collectionData: RemoteData) => isNotUndefined((collectionData.payload))), + flatMap((collectionData: RemoteData) => collectionData.payload.license), + find((licenseData: RemoteData) => isNotUndefined((licenseData.payload))), + map((licenseData: RemoteData) => licenseData.payload.text), + startWith('')); + + this.subs.push( + // Disable checkbox whether it's in workflow or item scope + this.sectionService.isSectionReadOnly( + this.submissionId, + this.sectionData.id, + this.submissionService.getSubmissionScope()).pipe( + take(1), + filter((isReadOnly) => isReadOnly)) + .subscribe(() => { + model.disabledUpdates.next(true); + }), + + this.sectionService.getSectionErrors(this.submissionId, this.sectionData.id).pipe( + filter((errors) => isNotEmpty(errors)), + distinctUntilChanged()) + .subscribe((errors) => { + // parse errors + const newErrors = errors.map((error) => { + // When the error path is only on the section, + // replace it with the path to the form field to display error also on the form + if (error.path === '/sections/license') { + // check whether license is not accepted + if (!(model as DynamicCheckboxModel).checked) { + return Object.assign({}, error, { path: '/sections/license/granted' }); + } else { + return null; + } + } else { + return error; + } + }).filter((error) => isNotNull(error)); + + if (isNotEmpty(newErrors)) { + this.sectionService.checkSectionErrors(this.submissionId, this.sectionData.id, this.formId, newErrors); + this.sectionData.errors = errors; + } else { + // Remove any section's errors + this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionData.id); + } + this.changeDetectorRef.detectChanges(); + }) + ); + } + + /** + * Get section status + * + * @return Observable + * the section status + */ + protected getSectionStatus(): Observable { + const model = this.formBuilderService.findById('granted', this.formModel); + return (model as DynamicCheckboxModel).valueUpdates.pipe( + map((value) => value === true), + startWith((model as DynamicCheckboxModel).value)); + } + + /** + * Method called when a form dfChange event is fired. + * Dispatch form operations based on changes. + */ + onChange(event: DynamicFormControlEvent) { + const path = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event); + const value = this.formOperationsService.getFieldValueFromChangeEvent(event); + if (value) { + this.operationsBuilder.add(this.pathCombiner.getPath(path), value.value.toString(), false, true); + // Remove any section's errors + this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionData.id); + } else { + this.operationsBuilder.remove(this.pathCombiner.getPath(path)); + } + } + + /** + * Unsubscribe from all subscriptions + */ + onSectionDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/submission/sections/license/section-license.model.ts b/src/app/submission/sections/license/section-license.model.ts new file mode 100644 index 0000000000..8107b73674 --- /dev/null +++ b/src/app/submission/sections/license/section-license.model.ts @@ -0,0 +1,28 @@ + +export const SECTION_LICENSE_FORM_LAYOUT = { + + granted: { + element: { + container: 'custom-control custom-checkbox pl-1', + control: 'custom-control-input', + label: 'custom-control-label pt-1' + } + } +}; + +export const SECTION_LICENSE_FORM_MODEL = [ + { + id: 'granted', + label: 'I confirm the license above', + required: true, + value: false, + validators: { + required: null + }, + errorMessages: { + required: 'You must accept the license', + notgranted: 'You must accept the license' + }, + type: 'CHECKBOX', + } +]; diff --git a/src/app/submission/sections/models/section-data.model.ts b/src/app/submission/sections/models/section-data.model.ts new file mode 100644 index 0000000000..8feb78fa69 --- /dev/null +++ b/src/app/submission/sections/models/section-data.model.ts @@ -0,0 +1,49 @@ +import { SubmissionSectionError } from '../../objects/submission-objects.reducer'; +import { WorkspaceitemSectionDataType } from '../../../core/submission/models/workspaceitem-sections.model'; +import { SectionsType } from '../sections-type'; + +/** + * An interface to represent section model + */ +export interface SectionDataObject { + + /** + * The section configuration url + */ + config: string; + + /** + * The section data object + */ + data: WorkspaceitemSectionDataType; + + /** + * The list of the section errors + */ + errors: SubmissionSectionError[]; + + /** + * The section header + */ + header: string; + + /** + * The section id + */ + id: string; + + /** + * A boolean representing if this section is mandatory + */ + mandatory: boolean; + + /** + * The section type + */ + sectionType: SectionsType; + + /** + * Eventually additional fields + */ + [propName: string]: any; +} diff --git a/src/app/submission/sections/models/section.model.ts b/src/app/submission/sections/models/section.model.ts new file mode 100644 index 0000000000..4e9821dcd1 --- /dev/null +++ b/src/app/submission/sections/models/section.model.ts @@ -0,0 +1,120 @@ +import { Inject, OnDestroy, OnInit } from '@angular/core'; + +import { Observable, Subscription } from 'rxjs'; +import { filter, startWith } from 'rxjs/operators'; + +import { SectionDataObject } from './section-data.model'; +import { SectionsService } from '../sections.service'; +import { hasValue, isNotUndefined } from '../../../shared/empty.util'; + +export interface SectionDataModel { + sectionData: SectionDataObject +} + +/** + * An abstract model class for a submission edit form section. + */ +export abstract class SectionModelComponent implements OnDestroy, OnInit, SectionDataModel { + protected abstract sectionService: SectionsService; + + /** + * The collection id this submission belonging to + * @type {string} + */ + collectionId: string; + + /** + * The section data + * @type {SectionDataObject} + */ + sectionData: SectionDataObject; + + /** + * The submission id + * @type {string} + */ + submissionId: string; + + /** + * A boolean representing if this section is valid + * @type {boolean} + */ + protected valid: boolean; + + /** + * The Subscription to section status observable + * @type {Subscription} + */ + private sectionStatusSub: Subscription; + + /** + * Initialize instance variables + * + * @param {string} injectedCollectionId + * @param {SectionDataObject} injectedSectionData + * @param {string} injectedSubmissionId + */ + public constructor(@Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + this.collectionId = injectedCollectionId; + this.sectionData = injectedSectionData; + this.submissionId = injectedSubmissionId; + } + + /** + * Call abstract methods on component init + */ + ngOnInit(): void { + this.onSectionInit(); + this.updateSectionStatus(); + } + + /** + * Abstract method to implement to get section status + * + * @return Observable + * the section status + */ + protected abstract getSectionStatus(): Observable; + + /** + * Abstract method called on component init. + * It must be used instead of ngOnInit on the component that extend this abstract class + * + * @return Observable + * the section status + */ + protected abstract onSectionInit(): void; + + /** + * Abstract method called on component destroy. + * It must be used instead of ngOnDestroy on the component that extend this abstract class + * + * @return Observable + * the section status + */ + protected abstract onSectionDestroy(): void; + + /** + * Subscribe to section status + */ + protected updateSectionStatus(): void { + this.sectionStatusSub = this.getSectionStatus().pipe( + filter((sectionStatus: boolean) => isNotUndefined(sectionStatus)), + startWith(true)) + .subscribe((sectionStatus: boolean) => { + this.sectionService.setSectionStatus(this.submissionId, this.sectionData.id, sectionStatus); + }); + } + + /** + * Unsubscribe from all subscriptions and Call abstract methods on component destroy + */ + ngOnDestroy(): void { + if (hasValue(this.sectionStatusSub)) { + this.sectionStatusSub.unsubscribe(); + } + this.onSectionDestroy(); + } +} diff --git a/src/app/submission/sections/sections-decorator.ts b/src/app/submission/sections/sections-decorator.ts new file mode 100644 index 0000000000..7e7840adfd --- /dev/null +++ b/src/app/submission/sections/sections-decorator.ts @@ -0,0 +1,16 @@ + +import { SectionsType } from './sections-type'; + +const submissionSectionsMap = new Map(); +export function renderSectionFor(sectionType: SectionsType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + submissionSectionsMap.set(sectionType, objectElement); + }; +} + +export function rendersSectionType(sectionType: SectionsType) { + return submissionSectionsMap.get(sectionType); +} diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts new file mode 100644 index 0000000000..02e0ba478b --- /dev/null +++ b/src/app/submission/sections/sections-type.ts @@ -0,0 +1,7 @@ +export enum SectionsType { + SubmissionForm = 'submission-form', + Upload = 'upload', + License = 'license', + CcLicense = 'cclicense', + collection = 'collection' +} diff --git a/src/app/submission/sections/sections.directive.ts b/src/app/submission/sections/sections.directive.ts new file mode 100644 index 0000000000..0efb7225aa --- /dev/null +++ b/src/app/submission/sections/sections.directive.ts @@ -0,0 +1,290 @@ +import { ChangeDetectorRef, Directive, Input, OnDestroy, OnInit } from '@angular/core'; + +import { Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { uniq } from 'lodash'; + +import { SectionsService } from './sections.service'; +import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { SubmissionSectionError, SubmissionSectionObject } from '../objects/submission-objects.reducer'; +import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; +import { SubmissionService } from '../submission.service'; + +/** + * Directive for handling generic section functionality + */ +@Directive({ + selector: '[dsSection]', + exportAs: 'sectionRef' +}) +export class SectionsDirective implements OnDestroy, OnInit { + + /** + * A boolean representing if section is mandatory + * @type {boolean} + */ + @Input() mandatory = true; + + /** + * The section id + * @type {string} + */ + @Input() sectionId: string; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * The list of generic errors related to the section + * @type {Array} + */ + public genericSectionErrors: string[] = []; + + /** + * The list of all errors related to the element belonging to this section + * @type {Array} + */ + public allSectionErrors: string[] = []; + + /** + * A boolean representing if section is active + * @type {boolean} + */ + private active = true; + + /** + * A boolean representing if section is enabled + * @type {boolean} + */ + private enabled: Observable; + + /** + * A boolean representing the panel collapsible state: opened (true) or closed (false) + * @type {boolean} + */ + private sectionState = this.mandatory; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * A boolean representing if section is valid + * @type {boolean} + */ + private valid: Observable; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} changeDetectorRef + * @param {SubmissionService} submissionService + * @param {SectionsService} sectionService + */ + constructor(private changeDetectorRef: ChangeDetectorRef, + private submissionService: SubmissionService, + private sectionService: SectionsService) { + } + + /** + * Initialize instance variables + */ + ngOnInit() { + this.valid = this.sectionService.isSectionValid(this.submissionId, this.sectionId).pipe( + map((valid: boolean) => { + if (valid) { + this.resetErrors(); + } + return valid; + })); + + this.subs.push( + this.sectionService.getSectionState(this.submissionId, this.sectionId).pipe( + map((state: SubmissionSectionObject) => state.errors)) + .subscribe((errors: SubmissionSectionError[]) => { + if (isNotEmpty(errors)) { + errors.forEach((errorItem: SubmissionSectionError) => { + const parsedErrors: SectionErrorPath[] = parseSectionErrorPaths(errorItem.path); + + parsedErrors.forEach((error: SectionErrorPath) => { + if (!error.fieldId) { + this.genericSectionErrors = uniq(this.genericSectionErrors.concat(errorItem.message)); + } else { + this.allSectionErrors.push(errorItem.message); + } + }); + }); + } else { + this.resetErrors(); + } + }), + this.submissionService.getActiveSectionId(this.submissionId) + .subscribe((activeSectionId) => { + const previousActive = this.active; + this.active = (activeSectionId === this.sectionId); + if (previousActive !== this.active) { + this.changeDetectorRef.detectChanges(); + // If section is no longer active dispatch save action + if (!this.active && isNotNull(activeSectionId)) { + this.submissionService.dispatchSave(this.submissionId); + } + } + }) + ); + + this.enabled = this.sectionService.isSectionEnabled(this.submissionId, this.sectionId); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + /** + * Change section state + * + * @param event + * the event emitted + */ + public sectionChange(event) { + this.sectionState = event.nextState; + } + + /** + * Check if section panel is open + * + * @returns {boolean} + * Returns true when section panel is open + */ + public isOpen(): boolean { + return this.sectionState; + } + + /** + * Check if section is mandatory + * + * @returns {boolean} + * Returns true when section is mandatory + */ + public isMandatory(): boolean { + return this.mandatory; + } + + /** + * Check if section panel is active + * + * @returns {boolean} + * Returns true when section panel is active + */ + public isSectionActive(): boolean { + return this.active; + } + + /** + * Check if section is enabled + * + * @returns {Observable} + * Emits true whenever section is enabled + */ + public isEnabled(): Observable { + return this.enabled; + } + + /** + * Check if section is valid + * + * @returns {Observable} + * Emits true whenever section is valid + */ + public isValid(): Observable { + return this.valid; + } + + /** + * Remove section panel from submission form + * + * @param submissionId + * the submission id + * @param sectionId + * the section id + * @returns {Observable} + * Emits true whenever section is valid + */ + public removeSection(submissionId: string, sectionId: string) { + this.sectionService.removeSection(submissionId, sectionId) + } + + /** + * Check if section has only generic errors + * + * @returns {boolean} + * Returns true when section has only generic errors + */ + public hasGenericErrors(): boolean { + return this.genericSectionErrors && this.genericSectionErrors.length > 0 + } + + /** + * Check if section has errors + * + * @returns {boolean} + * Returns true when section has errors + */ + public hasErrors(): boolean { + return (this.genericSectionErrors && this.genericSectionErrors.length > 0) || + (this.allSectionErrors && this.allSectionErrors.length > 0) + } + + /** + * Return section errors + * + * @returns {Array} + * Returns section errors list + */ + public getErrors(): string[] { + return this.genericSectionErrors; + } + + /** + * Set form focus to this section panel + * + * @param event + * The event emitted + */ + public setFocus(event): void { + if (!this.active) { + this.submissionService.setActiveSection(this.submissionId, this.sectionId); + } + } + + /** + * Remove error from list + * + * @param index + * The error array key + */ + public removeError(index): void { + this.genericSectionErrors.splice(index); + } + + /** + * Remove all errors from list + */ + public resetErrors() { + if (isNotEmpty(this.genericSectionErrors)) { + this.sectionService.dispatchRemoveSectionErrors(this.submissionId, this.sectionId); + } + this.genericSectionErrors = []; + this.allSectionErrors = []; + + } +} diff --git a/src/app/submission/sections/sections.service.spec.ts b/src/app/submission/sections/sections.service.spec.ts new file mode 100644 index 0000000000..4a1cea5972 --- /dev/null +++ b/src/app/submission/sections/sections.service.spec.ts @@ -0,0 +1,374 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { Store, StoreModule } from '@ngrx/store'; +import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { submissionReducers } from '../submission.reducers'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionService } from '../submission.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service-stub'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; +import { SectionsService } from './sections.service'; +import { mockSectionsData, mockSectionsErrors, mockSubmissionState } from '../../shared/mocks/mock-submission'; +import { + DisableSectionAction, + EnableSectionAction, + InertSectionErrorsAction, + RemoveSectionErrorsAction, + SectionStatusChangeAction, + UpdateSectionDataAction +} from '../objects/submission-objects.actions'; +import { FormAddError, FormClearErrorsAction, FormRemoveErrorAction } from '../../shared/form/form.actions'; +import parseSectionErrors from '../utils/parseSectionErrors'; +import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; +import { SubmissionSectionError } from '../objects/submission-objects.reducer'; +import { getMockScrollToService } from '../../shared/mocks/mock-scroll-to-service'; + +describe('SectionsService test suite', () => { + let notificationsServiceStub: NotificationsServiceStub; + let scrollToService: ScrollToService; + let service: SectionsService; + let submissionServiceStub: SubmissionServiceStub; + let translateService: any; + + const formId = 'formTest'; + const submissionId = '826'; + const sectionId = 'traditionalpageone'; + const sectionErrors: any = parseSectionErrors(mockSectionsErrors); + const sectionData: any = mockSectionsData; + const submissionState: any = Object.assign({}, mockSubmissionState[submissionId]); + const sectionState: any = Object.assign({}, mockSubmissionState['826'].sections[sectionId]); + + const store: any = jasmine.createSpyObj('store', { + dispatch: jasmine.createSpy('dispatch'), + select: jasmine.createSpy('select') + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ submissionReducers } as any), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + providers: [ + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: ScrollToService, useValue: getMockScrollToService() }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: Store, useValue: store }, + SectionsService + ] + }).compileComponents(); + })); + + beforeEach(() => { + service = TestBed.get(SectionsService); + submissionServiceStub = TestBed.get(SubmissionService); + notificationsServiceStub = TestBed.get(NotificationsService); + scrollToService = TestBed.get(ScrollToService); + translateService = TestBed.get(TranslateService); + }); + + describe('checkSectionErrors', () => { + it('should dispatch a new RemoveSectionErrorsAction and FormClearErrorsAction when there are no errors', () => { + service.checkSectionErrors(submissionId, sectionId, formId, []); + + expect(store.dispatch).toHaveBeenCalledWith(new RemoveSectionErrorsAction(submissionId, sectionId)); + expect(store.dispatch).toHaveBeenCalledWith(new FormClearErrorsAction(formId)); + }); + + it('should dispatch a new FormAddError for each section\'s error', () => { + service.checkSectionErrors(submissionId, sectionId, formId, sectionErrors[sectionId]); + + expect(store.dispatch).toHaveBeenCalledWith(new FormAddError( + formId, + 'dc_contributor_author', + 0, + 'error.validation.required')); + + expect(store.dispatch).toHaveBeenCalledWith(new FormAddError( + formId, + 'dc_title', + 0, + 'error.validation.required')); + + expect(store.dispatch).toHaveBeenCalledWith(new FormAddError(formId, + 'dc_date_issued', + 0, + 'error.validation.required')); + }); + + it('should dispatch a new FormRemoveErrorAction for each section\'s error that no longer exists', () => { + const currentErrors = Array.of(...sectionErrors[sectionId]); + const prevErrors = Array.of(...sectionErrors[sectionId]); + currentErrors.pop(); + + service.checkSectionErrors(submissionId, sectionId, formId, currentErrors, prevErrors); + + expect(store.dispatch).toHaveBeenCalledWith(new FormAddError( + formId, + 'dc_contributor_author', + 0, + 'error.validation.required')); + + expect(store.dispatch).toHaveBeenCalledWith(new FormAddError( + formId, + 'dc_title', + 0, + 'error.validation.required')); + expect(store.dispatch).toHaveBeenCalledWith(new FormRemoveErrorAction( + formId, + 'dc_date_issued', + 0)); + }); + }); + + describe('dispatchRemoveSectionErrors', () => { + it('should dispatch a new RemoveSectionErrorsAction', () => { + service.dispatchRemoveSectionErrors(submissionId, sectionId); + const expected = new RemoveSectionErrorsAction(submissionId, sectionId); + + expect(store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('getSectionData', () => { + it('should return an observable with section\'s data', () => { + store.select.and.returnValue(observableOf(sectionData[sectionId])); + + const expected = cold('(b|)', { + b: sectionData[sectionId] + }); + + expect(service.getSectionData(submissionId, sectionId)).toBeObservable(expected); + }); + }); + + describe('getSectionErrors', () => { + it('should return an observable with section\'s errors', () => { + store.select.and.returnValue(observableOf(sectionErrors[sectionId])); + + const expected = cold('(b|)', { + b: sectionErrors[sectionId] + }); + + expect(service.getSectionErrors(submissionId, sectionId)).toBeObservable(expected); + }); + }); + + describe('getSectionState', () => { + it('should return an observable with section\'s state', () => { + store.select.and.returnValue(observableOf(sectionState)); + + const expected = cold('(b|)', { + b: sectionState + }); + + expect(service.getSectionState(submissionId, sectionId)).toBeObservable(expected); + }); + }); + + describe('isSectionValid', () => { + it('should return an observable of boolean', () => { + store.select.and.returnValue(observableOf({ isValid: false })); + + let expected = cold('(b|)', { + b: false + }); + + expect(service.isSectionValid(submissionId, sectionId)).toBeObservable(expected); + + store.select.and.returnValue(observableOf({ isValid: true })); + + expected = cold('(b|)', { + b: true + }); + + expect(service.isSectionValid(submissionId, sectionId)).toBeObservable(expected); + }); + }); + + describe('isSectionActive', () => { + it('should return an observable of boolean', () => { + submissionServiceStub.getActiveSectionId.and.returnValue(observableOf(sectionId)); + + let expected = cold('(b|)', { + b: true + }); + + expect(service.isSectionActive(submissionId, sectionId)).toBeObservable(expected); + + submissionServiceStub.getActiveSectionId.and.returnValue(observableOf('test')); + + expected = cold('(b|)', { + b: false + }); + + expect(service.isSectionActive(submissionId, sectionId)).toBeObservable(expected); + }); + }); + + describe('isSectionEnabled', () => { + it('should return an observable of boolean', () => { + store.select.and.returnValue(observableOf({ enabled: false })); + + let expected = cold('(b|)', { + b: false + }); + + expect(service.isSectionEnabled(submissionId, sectionId)).toBeObservable(expected); + + store.select.and.returnValue(observableOf({ enabled: true })); + + expected = cold('(b|)', { + b: true + }); + + expect(service.isSectionEnabled(submissionId, sectionId)).toBeObservable(expected); + }); + }); + + describe('isSectionReadOnly', () => { + it('should return an observable of true when it\'s a readonly section and scope is not workspace', () => { + store.select.and.returnValue(observableOf({ + visibility: { + main: null, + other: 'READONLY' + } + })); + + const expected = cold('(b|)', { + b: true + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + + it('should return an observable of false when it\'s a readonly section and scope is workspace', () => { + store.select.and.returnValue(observableOf({ + visibility: { + main: null, + other: 'READONLY' + } + })); + + const expected = cold('(b|)', { + b: false + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkspaceItem)).toBeObservable(expected); + }); + + it('should return an observable of false when it\'s not a readonly section', () => { + store.select.and.returnValue(observableOf({ + visibility: null + })); + + const expected = cold('(b|)', { + b: false + }); + + expect(service.isSectionReadOnly(submissionId, sectionId, SubmissionScopeType.WorkflowItem)).toBeObservable(expected); + }); + }); + + describe('isSectionAvailable', () => { + it('should return an observable of true when section is available', () => { + store.select.and.returnValue(observableOf(submissionState)); + + const expected = cold('(b|)', { + b: true + }); + + expect(service.isSectionAvailable(submissionId, sectionId)).toBeObservable(expected); + }); + + it('should return an observable of false when section is not available', () => { + store.select.and.returnValue(observableOf(submissionState)); + + const expected = cold('(b|)', { + b: false + }); + + expect(service.isSectionAvailable(submissionId, 'test')).toBeObservable(expected); + }); + }); + + describe('addSection', () => { + it('should dispatch a new EnableSectionAction a move target to new section', () => { + + service.addSection(submissionId, 'newSection'); + + expect(store.dispatch).toHaveBeenCalledWith(new EnableSectionAction(submissionId, 'newSection')); + expect(scrollToService.scrollTo).toHaveBeenCalled(); + }); + }); + + describe('removeSection', () => { + it('should dispatch a new DisableSectionAction', () => { + + service.removeSection(submissionId, 'newSection'); + + expect(store.dispatch).toHaveBeenCalledWith(new DisableSectionAction(submissionId, 'newSection')); + }); + }); + + describe('setSectionError', () => { + it('should dispatch a new InertSectionErrorsAction', () => { + + const error: SubmissionSectionError = { + path: 'test', + message: 'message test' + }; + service.setSectionError(submissionId, sectionId, error); + + expect(store.dispatch).toHaveBeenCalledWith(new InertSectionErrorsAction(submissionId, sectionId, error)); + }); + }); + + describe('setSectionStatus', () => { + it('should dispatch a new SectionStatusChangeAction', () => { + + service.setSectionStatus(submissionId, sectionId, true); + + expect(store.dispatch).toHaveBeenCalledWith(new SectionStatusChangeAction(submissionId, sectionId, true)); + }); + }); + + describe('updateSectionData', () => { + + it('should dispatch a new UpdateSectionDataAction', () => { + const scheduler = getTestScheduler(); + const data: any = { test: 'test' }; + spyOn(service, 'isSectionAvailable').and.returnValue(observableOf(true)); + spyOn(service, 'isSectionEnabled').and.returnValue(observableOf(true)); + scheduler.schedule(() => service.updateSectionData(submissionId, sectionId, data, [])); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSectionDataAction(submissionId, sectionId, data, [])); + }); + + it('should dispatch a new UpdateSectionDataAction and display a new notification when section is not enabled', () => { + const scheduler = getTestScheduler(); + const data: any = { test: 'test' }; + spyOn(service, 'isSectionAvailable').and.returnValue(observableOf(true)); + spyOn(service, 'isSectionEnabled').and.returnValue(observableOf(false)); + translateService.get.and.returnValue(observableOf('test')); + scheduler.schedule(() => service.updateSectionData(submissionId, sectionId, data, [])); + scheduler.flush(); + + expect(notificationsServiceStub.info).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSectionDataAction(submissionId, sectionId, data, [])); + }); + }); +}); diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts new file mode 100644 index 0000000000..aed83143a5 --- /dev/null +++ b/src/app/submission/sections/sections.service.ts @@ -0,0 +1,360 @@ +import { Injectable } from '@angular/core'; + +import { combineLatest, Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; +import { isEqual } from 'lodash'; + +import { SubmissionState } from '../submission.reducers'; +import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { + DisableSectionAction, + EnableSectionAction, + InertSectionErrorsAction, + RemoveSectionErrorsAction, + SectionStatusChangeAction, + UpdateSectionDataAction +} from '../objects/submission-objects.actions'; +import { + SubmissionObjectEntry, + SubmissionSectionError, + SubmissionSectionObject +} from '../objects/submission-objects.reducer'; +import { + submissionObjectFromIdSelector, + submissionSectionDataFromIdSelector, + submissionSectionErrorsFromIdSelector, + submissionSectionFromIdSelector +} from '../selectors'; +import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; +import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; +import { FormAddError, FormClearErrorsAction, FormRemoveErrorAction } from '../../shared/form/form.actions'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionService } from '../submission.service'; +import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model'; + +/** + * A service that provides methods used in submission process. + */ +@Injectable() +export class SectionsService { + + /** + * Initialize service variables + * @param {NotificationsService} notificationsService + * @param {ScrollToService} scrollToService + * @param {SubmissionService} submissionService + * @param {Store} store + * @param {TranslateService} translate + */ + constructor(private notificationsService: NotificationsService, + private scrollToService: ScrollToService, + private submissionService: SubmissionService, + private store: Store, + private translate: TranslateService) { + } + + /** + * Compare the list of the current section errors with the previous one, + * and dispatch actions to add/remove to/from the section state + * + * @param submissionId + * The submission id + * @param sectionId + * The workspaceitem self url + * @param formId + * The [SubmissionDefinitionsModel] that define submission configuration + * @param currentErrors + * The [SubmissionSectionError] that define submission sections init data + * @param prevErrors + * The [SubmissionSectionError] that define submission sections init errors + */ + public checkSectionErrors( + submissionId: string, + sectionId: string, + formId: string, + currentErrors: SubmissionSectionError[], + prevErrors: SubmissionSectionError[] = []) { + // Remove previous error list if the current is empty + if (isEmpty(currentErrors)) { + this.store.dispatch(new RemoveSectionErrorsAction(submissionId, sectionId)); + this.store.dispatch(new FormClearErrorsAction(formId)); + } else if (!isEqual(currentErrors, prevErrors)) { // compare previous error list with the current one + const dispatchedErrors = []; + + // Itereate over the current error list + currentErrors.forEach((error: SubmissionSectionError) => { + const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); + + errorPaths.forEach((path: SectionErrorPath) => { + if (path.fieldId) { + const fieldId = path.fieldId.replace(/\./g, '_'); + + // Dispatch action to add form error to the state; + const formAddErrorAction = new FormAddError(formId, fieldId, path.fieldIndex, error.message); + this.store.dispatch(formAddErrorAction); + dispatchedErrors.push(fieldId); + } + }); + }); + + // Itereate over the previous error list + prevErrors.forEach((error: SubmissionSectionError) => { + const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); + + errorPaths.forEach((path: SectionErrorPath) => { + if (path.fieldId) { + const fieldId = path.fieldId.replace(/\./g, '_'); + + if (!dispatchedErrors.includes(fieldId)) { + // Dispatch action to remove form error from the state; + const formRemoveErrorAction = new FormRemoveErrorAction(formId, fieldId, path.fieldIndex); + this.store.dispatch(formRemoveErrorAction); + } + } + }); + }); + } + } + + /** + * Dispatch a new [RemoveSectionErrorsAction] + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + */ + public dispatchRemoveSectionErrors(submissionId, sectionId) { + this.store.dispatch(new RemoveSectionErrorsAction(submissionId, sectionId)); + } + + /** + * Return the data object for the specified section + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @return Observable + * observable of [WorkspaceitemSectionDataType] + */ + public getSectionData(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionSectionDataFromIdSelector(submissionId, sectionId)).pipe( + distinctUntilChanged()); + } + + /** + * Return the error list object data for the specified section + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @return Observable + * observable of array of [SubmissionSectionError] + */ + public getSectionErrors(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionSectionErrorsFromIdSelector(submissionId, sectionId)).pipe( + distinctUntilChanged()); + } + + /** + * Return the state object for the specified section + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @return Observable + * observable of [SubmissionSectionObject] + */ + public getSectionState(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)).pipe( + filter((sectionObj: SubmissionSectionObject) => hasValue(sectionObj)), + map((sectionObj: SubmissionSectionObject) => sectionObj), + distinctUntilChanged()); + } + + /** + * Check if a given section is valid + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @return Observable + * Emits true whenever a given section should be valid + */ + public isSectionValid(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)).pipe( + filter((sectionObj) => hasValue(sectionObj)), + map((sectionObj: SubmissionSectionObject) => sectionObj.isValid), + distinctUntilChanged()); + } + + /** + * Check if a given section is active + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @return Observable + * Emits true whenever a given section should be active + */ + public isSectionActive(submissionId: string, sectionId: string): Observable { + return this.submissionService.getActiveSectionId(submissionId).pipe( + map((activeSectionId: string) => sectionId === activeSectionId), + distinctUntilChanged()); + } + + /** + * Check if a given section is enabled + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @return Observable + * Emits true whenever a given section should be enabled + */ + public isSectionEnabled(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)).pipe( + filter((sectionObj) => hasValue(sectionObj)), + map((sectionObj: SubmissionSectionObject) => sectionObj.enabled), + distinctUntilChanged()); + } + + /** + * Check if a given section is a read only section + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param submissionScope + * The submission scope + * @return Observable + * Emits true whenever a given section should be read only + */ + public isSectionReadOnly(submissionId: string, sectionId: string, submissionScope: SubmissionScopeType): Observable { + return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)).pipe( + filter((sectionObj) => hasValue(sectionObj)), + map((sectionObj: SubmissionSectionObject) => { + return isNotEmpty(sectionObj.visibility) + && sectionObj.visibility.other === 'READONLY' + && submissionScope !== SubmissionScopeType.WorkspaceItem + }), + distinctUntilChanged()); + } + + /** + * Check if a given section is a read only available + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @return Observable + * Emits true whenever a given section should be available + */ + public isSectionAvailable(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionObjectFromIdSelector(submissionId)).pipe( + filter((submissionState: SubmissionObjectEntry) => isNotUndefined(submissionState)), + map((submissionState: SubmissionObjectEntry) => { + return isNotUndefined(submissionState.sections) && isNotUndefined(submissionState.sections[sectionId]); + }), + distinctUntilChanged()); + } + + /** + * Dispatch a new [EnableSectionAction] to add a new section and move page target to it + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + */ + public addSection(submissionId: string, sectionId: string) { + this.store.dispatch(new EnableSectionAction(submissionId, sectionId)); + const config: ScrollToConfigOptions = { + target: sectionId, + offset: -70 + }; + + this.scrollToService.scrollTo(config); + } + + /** + * Dispatch a new [DisableSectionAction] to remove section + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + */ + public removeSection(submissionId: string, sectionId: string) { + this.store.dispatch(new DisableSectionAction(submissionId, sectionId)) + } + + /** + * Dispatch a new [UpdateSectionDataAction] to update section state with new data and errors + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param data + * The section data + * @param errors + * The list of section errors + */ + public updateSectionData(submissionId: string, sectionId: string, data: WorkspaceitemSectionDataType, errors: SubmissionSectionError[] = []) { + if (isNotEmpty(data)) { + const isAvailable$ = this.isSectionAvailable(submissionId, sectionId); + const isEnabled$ = this.isSectionEnabled(submissionId, sectionId); + + combineLatest(isAvailable$, isEnabled$).pipe( + take(1), + filter(([available, enabled]: [boolean, boolean]) => available)) + .subscribe(([available, enabled]: [boolean, boolean]) => { + if (!enabled) { + const msg = this.translate.instant('submission.sections.general.metadata-extracted-new-section', {sectionId}); + this.notificationsService.info(null, msg, null, true); + } + this.store.dispatch(new UpdateSectionDataAction(submissionId, sectionId, data, errors)); + }); + } + } + + /** + * Dispatch a new [InertSectionErrorsAction] to update section state with new error + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param error + * The section error + */ + public setSectionError(submissionId: string, sectionId: string, error: SubmissionSectionError) { + this.store.dispatch(new InertSectionErrorsAction(submissionId, sectionId, error)); + } + + /** + * Dispatch a new [SectionStatusChangeAction] to update section state with new status + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param status + * The section status + */ + public setSectionStatus(submissionId: string, sectionId: string, status: boolean) { + this.store.dispatch(new SectionStatusChangeAction(submissionId, sectionId, status)); + } +} diff --git a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html new file mode 100644 index 0000000000..ae4c74e2eb --- /dev/null +++ b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html @@ -0,0 +1,9 @@ + + + {{accessCondition.name}} {{accessCondition.startDate}} {{accessCondition.endDate}} + + {{accessCondition.name}} + {{accessCondition.name}} from {{accessCondition.endDate}} + {{accessCondition.name}} until {{accessCondition.startDate}} +
    +
    diff --git a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts new file mode 100644 index 0000000000..43b0a7da3f --- /dev/null +++ b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts @@ -0,0 +1,58 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { find } from 'rxjs/operators'; + +import { GroupEpersonService } from '../../../../core/eperson/group-eperson.service'; +import { ResourcePolicy } from '../../../../core/shared/resource-policy.model'; +import { isEmpty } from '../../../../shared/empty.util'; +import { Group } from '../../../../core/eperson/models/group.model'; +import { RemoteData } from '../../../../core/data/remote-data'; + +/** + * This component represents a badge that describe an access condition + */ +@Component({ + selector: 'ds-submission-section-upload-access-conditions', + templateUrl: './submission-section-upload-access-conditions.component.html', +}) +export class SubmissionSectionUploadAccessConditionsComponent implements OnInit { + + /** + * The list of resource policy + * @type {Array} + */ + @Input() accessConditions: ResourcePolicy[]; + + /** + * The list of access conditions + * @type {Array} + */ + public accessConditionsList = []; + + /** + * Initialize instance variables + * + * @param {GroupEpersonService} groupService + */ + constructor(private groupService: GroupEpersonService) {} + + /** + * Retrieve access conditions list + */ + ngOnInit() { + this.accessConditions.forEach((accessCondition: ResourcePolicy) => { + if (isEmpty(accessCondition.name)) { + this.groupService.findById(accessCondition.groupUUID).pipe( + find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded)) + .subscribe((rd: RemoteData) => { + const group: Group = rd.payload; + const accessConditionEntry = Object.assign({}, accessCondition); + accessConditionEntry.name = group.name; + this.accessConditionsList.push(accessConditionEntry); + }) + } else { + this.accessConditionsList.push(accessCondition); + } + }) + } +} diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html new file mode 100644 index 0000000000..bfb322052c --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.html @@ -0,0 +1,8 @@ +
    + +
    diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts new file mode 100644 index 0000000000..4774b22924 --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts @@ -0,0 +1,220 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { BrowserModule } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { + DynamicFormArrayGroupModel, + DynamicFormArrayModel, + DynamicFormControlEvent, + DynamicFormGroupModel, + DynamicSelectModel +} from '@ng-dynamic-forms/core'; + +import { createTestComponent } from '../../../../../shared/testing/utils'; +import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service'; +import { SubmissionServiceStub } from '../../../../../shared/testing/submission-service-stub'; +import { SubmissionService } from '../../../../submission.service'; +import { SubmissionSectionUploadFileEditComponent } from './section-upload-file-edit.component'; +import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component'; +import { + mockGroup, + mockSubmissionCollectionId, + mockSubmissionId, + mockUploadConfigResponse, + mockUploadFiles +} from '../../../../../shared/mocks/mock-submission'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormComponent } from '../../../../../shared/form/form.component'; +import { FormService } from '../../../../../shared/form/form.service'; +import { GLOBAL_CONFIG } from '../../../../../../config'; +import { MOCK_SUBMISSION_CONFIG } from '../../../../../shared/testing/mock-submission-config'; +import { getMockFormService } from '../../../../../shared/mocks/mock-form-service'; +import { Group } from '../../../../../core/eperson/models/group.model'; + +describe('SubmissionSectionUploadFileEditComponent test suite', () => { + + let comp: SubmissionSectionUploadFileEditComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let formbuilderService: any; + + const config = MOCK_SUBMISSION_CONFIG; + const submissionId = mockSubmissionId; + const sectionId = 'upload'; + const collectionId = mockSubmissionCollectionId; + const availableAccessConditionOptions = mockUploadConfigResponse.accessConditionOptions; + const availableGroupsMap: Map = new Map([ + [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], + [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], + ]); + const collectionPolicyType = POLICY_DEFAULT_WITH_LIST; + const configMetadataForm: any = mockUploadConfigResponse.metadata; + const fileIndex = '0'; + const fileId = '123456-test-upload'; + const fileData: any = mockUploadFiles[0]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + declarations: [ + FormComponent, + SubmissionSectionUploadFileEditComponent, + TestComponent + ], + providers: [ + { provide: GLOBAL_CONFIG, useValue: config }, + { provide: FormService, useValue: getMockFormService() }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + FormBuilderService, + ChangeDetectorRef, + SubmissionSectionUploadFileEditComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + 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 SubmissionSectionUploadFileEditComponent', inject([SubmissionSectionUploadFileEditComponent], (app: SubmissionSectionUploadFileEditComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionUploadFileEditComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + formbuilderService = TestBed.get(FormBuilderService); + + comp.submissionId = submissionId; + comp.collectionId = collectionId; + comp.sectionId = sectionId; + comp.availableAccessConditionOptions = availableAccessConditionOptions; + comp.availableAccessConditionGroups = availableGroupsMap; + comp.collectionPolicyType = collectionPolicyType; + comp.fileIndex = fileIndex; + comp.fileId = fileId; + comp.configMetadataForm = configMetadataForm; + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should init form model properly', () => { + comp.fileData = fileData; + comp.formId = 'testFileForm'; + + comp.ngOnChanges(); + + expect(comp.formModel).toBeDefined(); + expect(comp.formModel.length).toBe(2); + expect(comp.formModel[0] instanceof DynamicFormGroupModel).toBeTruthy(); + expect(comp.formModel[1] instanceof DynamicFormArrayModel).toBeTruthy(); + expect((comp.formModel[1] as DynamicFormArrayModel).groups.length).toBe(2); + }); + + it('should call setOptions method onChange', () => { + const dynamicFormControlChangeEvent: DynamicFormControlEvent = { + $event: new Event('change'), + context: null, + control: null, + group: null, + model: {id: 'name'} as any, + type: 'change' + }; + spyOn(comp, 'setOptions'); + + comp.onChange(dynamicFormControlChangeEvent); + + expect(comp.setOptions).toHaveBeenCalled(); + }); + + it('should update form model on group select', () => { + + comp.fileData = fileData; + comp.formId = 'testFileForm'; + + comp.ngOnChanges(); + + const model: DynamicSelectModel = formbuilderService.findById('name', comp.formModel, 0); + const formGroup = formbuilderService.createFormGroup(comp.formModel); + const control = formbuilderService.getFormControlById('name', formGroup, comp.formModel, 0); + + spyOn(formbuilderService, 'findById').and.callThrough(); + + control.value = 'openaccess'; + comp.setOptions(model, control); + expect(formbuilderService.findById).not.toHaveBeenCalledWith('groupUUID', (model.parent as DynamicFormArrayGroupModel).group); + expect(formbuilderService.findById).not.toHaveBeenCalledWith('endDate', (model.parent as DynamicFormArrayGroupModel).group); + expect(formbuilderService.findById).not.toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group); + + control.value = 'lease'; + comp.setOptions(model, control); + expect(formbuilderService.findById).toHaveBeenCalledWith('groupUUID', (model.parent as DynamicFormArrayGroupModel).group); + expect(formbuilderService.findById).toHaveBeenCalledWith('endDate', (model.parent as DynamicFormArrayGroupModel).group); + + control.value = 'embargo'; + comp.setOptions(model, control); + expect(formbuilderService.findById).toHaveBeenCalledWith('groupUUID', (model.parent as DynamicFormArrayGroupModel).group); + expect(formbuilderService.findById).toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + availableGroups; + availableAccessConditionOptions; + collectionId = mockSubmissionCollectionId; + collectionPolicyType; + configMetadataForm$; + fileIndexes = []; + fileList = []; + fileNames = []; + sectionId = 'upload'; + submissionId = mockSubmissionId; +} diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts new file mode 100644 index 0000000000..b2575d1d58 --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -0,0 +1,372 @@ +import { ChangeDetectorRef, Component, Input, OnChanges, ViewChild } from '@angular/core'; +import { FormControl } from '@angular/forms'; + +import { + DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER, + DynamicDateControlModel, + DynamicDatePickerModel, + DynamicFormArrayGroupModel, + DynamicFormArrayModel, + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicSelectModel +} from '@ng-dynamic-forms/core'; + +import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service'; +import { + BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG, + BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT, + BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG, + BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT, + BITSTREAM_METADATA_FORM_GROUP_CONFIG, + BITSTREAM_METADATA_FORM_GROUP_LAYOUT +} from './section-upload-file-edit.model'; +import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component'; +import { isNotEmpty, isNotUndefined } from '../../../../../shared/empty.util'; +import { SubmissionFormsModel } from '../../../../../core/config/models/config-submission-forms.model'; +import { FormFieldModel } from '../../../../../shared/form/builder/models/form-field.model'; +import { AccessConditionOption } from '../../../../../core/config/models/config-access-condition-option.model'; +import { SubmissionService } from '../../../../submission.service'; +import { FormService } from '../../../../../shared/form/form.service'; +import { FormComponent } from '../../../../../shared/form/form.component'; +import { Group } from '../../../../../core/eperson/models/group.model'; + +/** + * This component represents the edit form for bitstream + */ +@Component({ + selector: 'ds-submission-section-upload-file-edit', + templateUrl: './section-upload-file-edit.component.html', +}) +export class SubmissionSectionUploadFileEditComponent implements OnChanges { + + /** + * The list of available access condition + * @type {Array} + */ + @Input() availableAccessConditionOptions: any[]; + + /** + * The list of available groups for an access condition + * @type {Array} + */ + @Input() availableAccessConditionGroups: Map; + + /** + * The submission id + * @type {string} + */ + @Input() collectionId: string; + + /** + * Define if collection access conditions policy type : + * POLICY_DEFAULT_NO_LIST : is not possible to define additional access group/s for the single file + * POLICY_DEFAULT_WITH_LIST : is possible to define additional access group/s for the single file + * @type {number} + */ + @Input() collectionPolicyType: number; + + /** + * The configuration for the bitstream's metadata form + * @type {SubmissionFormsModel} + */ + @Input() configMetadataForm: SubmissionFormsModel; + + /** + * The bitstream's metadata data + * @type {WorkspaceitemSectionUploadFileObject} + */ + @Input() fileData: WorkspaceitemSectionUploadFileObject; + + /** + * The bitstream id + * @type {string} + */ + @Input() fileId: string; + + /** + * The bitstream array key + * @type {string} + */ + @Input() fileIndex: string; + + /** + * The form id + * @type {string} + */ + @Input() formId: string; + + /** + * The section id + * @type {string} + */ + @Input() sectionId: string; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * The form model + * @type {DynamicFormControlModel[]} + */ + public formModel: DynamicFormControlModel[]; + + /** + * The FormComponent reference + */ + @ViewChild('formRef') public formRef: FormComponent; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} cdr + * @param {FormBuilderService} formBuilderService + * @param {FormService} formService + * @param {SubmissionService} submissionService + */ + constructor(private cdr: ChangeDetectorRef, + private formBuilderService: FormBuilderService, + private formService: FormService, + private submissionService: SubmissionService) { + } + + /** + * Dispatch form model init + */ + ngOnChanges() { + if (this.fileData && this.formId) { + this.formModel = this.buildFileEditForm(); + this.cdr.detectChanges(); + } + } + + /** + * Initialize form model + */ + protected buildFileEditForm() { + const configDescr: FormFieldModel = Object.assign({}, this.configMetadataForm.rows[0].fields[0]); + configDescr.repeatable = false; + const configForm = Object.assign({}, this.configMetadataForm, { + fields: Object.assign([], this.configMetadataForm.rows[0].fields[0], [ + this.configMetadataForm.rows[0].fields[0], + configDescr + ]) + }); + const formModel: DynamicFormControlModel[] = []; + const metadataGroupModelConfig = Object.assign({}, BITSTREAM_METADATA_FORM_GROUP_CONFIG); + metadataGroupModelConfig.group = this.formBuilderService.modelFromConfiguration( + configForm, + this.collectionId, + this.fileData.metadata, + this.submissionService.getSubmissionScope() + ); + formModel.push(new DynamicFormGroupModel(metadataGroupModelConfig, BITSTREAM_METADATA_FORM_GROUP_LAYOUT)); + const accessConditionTypeModelConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG); + const accessConditionsArrayConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG); + const accessConditionTypeOptions = []; + + if (this.collectionPolicyType === POLICY_DEFAULT_WITH_LIST) { + for (const accessCondition of this.availableAccessConditionOptions) { + accessConditionTypeOptions.push( + { + label: accessCondition.name, + value: accessCondition.name + } + ); + } + accessConditionTypeModelConfig.options = accessConditionTypeOptions; + + // Dynamically assign of relation in config. For startdate, endDate, groups. + const hasStart = []; + const hasEnd = []; + const hasGroups = []; + this.availableAccessConditionOptions.forEach((condition) => { + const showStart: boolean = condition.hasStartDate === true; + const showEnd: boolean = condition.hasEndDate === true; + const showGroups: boolean = showStart || showEnd; + if (showStart) { + hasStart.push({ id: 'name', value: condition.name }); + } + if (showEnd) { + hasEnd.push({ id: 'name', value: condition.name }); + } + if (showGroups) { + hasGroups.push({ id: 'name', value: condition.name }); + } + }); + const confStart = { relation: [{ action: 'ENABLE', connective: 'OR', when: hasStart }] }; + const confEnd = { relation: [{ action: 'ENABLE', connective: 'OR', when: hasEnd }] }; + const confGroup = { relation: [{ action: 'ENABLE', connective: 'OR', when: hasGroups }] }; + + accessConditionsArrayConfig.groupFactory = () => { + const type = new DynamicSelectModel(accessConditionTypeModelConfig, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT); + const startDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG, confStart); + const endDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, confEnd); + const groupsConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, confGroup); + + const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); + const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); + const groups = new DynamicSelectModel(groupsConfig, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT); + + return [type, startDate, endDate, groups]; + }; + + // Number of access conditions blocks in form + accessConditionsArrayConfig.initialCount = isNotEmpty(this.fileData.accessConditions) ? this.fileData.accessConditions.length : 1; + formModel.push( + new DynamicFormArrayModel(accessConditionsArrayConfig, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT) + ); + + } + this.initModelData(formModel); + return formModel; + } + + /** + * Initialize form model values + * + * @param formModel + * The form model + */ + public initModelData(formModel: DynamicFormControlModel[]) { + this.fileData.accessConditions.forEach((accessCondition, index) => { + Array.of('name', 'groupUUID', 'startDate', 'endDate') + .filter((key) => accessCondition.hasOwnProperty(key)) + .forEach((key) => { + const metadataModel: any = this.formBuilderService.findById(key, formModel, index); + if (metadataModel) { + if (key === 'groupUUID' && this.availableAccessConditionGroups.get(accessCondition.name)) { + this.availableAccessConditionGroups.get(accessCondition.name).forEach((group) => { + metadataModel.options.push({ + label: group.name, + value: group.uuid + }) + }); + } + if (metadataModel.type === DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER) { + const date = new Date(accessCondition[key]); + metadataModel.value = { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate() + } + } else { + metadataModel.value = accessCondition[key]; + } + } + }); + }); + } + + /** + * Dispatch form model update when changing an access condition + * + * @param event + * The event emitted + */ + public onChange(event: DynamicFormControlEvent) { + if (event.model.id === 'name') { + this.setOptions(event.model, event.control); + } + } + + /** + * Update `startDate`, 'groupUUID' and 'endDate' model + * + * @param model + * The [[DynamicFormControlModel]] object + * @param control + * The [[FormControl]] object + */ + public setOptions(model: DynamicFormControlModel, control: FormControl) { + let accessCondition: AccessConditionOption = null; + this.availableAccessConditionOptions.filter((element) => element.name === control.value) + .forEach((element) => accessCondition = element); + if (isNotEmpty(accessCondition)) { + const showGroups: boolean = accessCondition.hasStartDate === true || accessCondition.hasEndDate === true; + + const groupControl: FormControl = control.parent.get('groupUUID') as FormControl; + const startDateControl: FormControl = control.parent.get('startDate') as FormControl; + const endDateControl: FormControl = control.parent.get('endDate') as FormControl; + + // Clear previous state + groupControl.markAsUntouched(); + startDateControl.markAsUntouched(); + endDateControl.markAsUntouched(); + + // Clear previous values + if (showGroups) { + groupControl.setValue(null); + } else { + groupControl.clearValidators(); + groupControl.setValue(accessCondition.groupUUID); + } + startDateControl.setValue(null); + control.parent.markAsDirty(); + endDateControl.setValue(null); + + if (showGroups) { + if (isNotUndefined(accessCondition.groupUUID) || isNotUndefined(accessCondition.selectGroupUUID)) { + + const groupOptions = []; + if (isNotUndefined(this.availableAccessConditionGroups.get(accessCondition.name))) { + const groupModel = this.formBuilderService.findById( + 'groupUUID', + (model.parent as DynamicFormArrayGroupModel).group) as DynamicSelectModel; + + this.availableAccessConditionGroups.get(accessCondition.name).forEach((group) => { + groupOptions.push({ + label: group.name, + value: group.uuid + }) + }); + + // Due to a bug can't dynamically change the select options, so replace the model with a new one + const confGroup = { relation: groupModel.relation }; + const groupsConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, confGroup); + groupsConfig.options = groupOptions; + (model.parent as DynamicFormGroupModel).group.pop(); + (model.parent as DynamicFormGroupModel).group.push(new DynamicSelectModel(groupsConfig, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT)); + } + + } + if (accessCondition.hasStartDate) { + const startDateModel = this.formBuilderService.findById( + 'startDate', + (model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel; + + const min = new Date(accessCondition.maxStartDate); + startDateModel.max = { + year: min.getFullYear(), + month: min.getMonth() + 1, + day: min.getDate() + }; + } + if (accessCondition.hasEndDate) { + const endDateModel = this.formBuilderService.findById( + 'endDate', + (model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel; + + const max = new Date(accessCondition.maxEndDate); + endDateModel.max = { + year: max.getFullYear(), + month: max.getMonth() + 1, + day: max.getDate() + }; + } + } + } + } + +} diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts new file mode 100644 index 0000000000..ec72adf786 --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts @@ -0,0 +1,136 @@ +import { + DynamicDatePickerModelConfig, + DynamicFormArrayModelConfig, + DynamicSelectModelConfig, + DynamicFormGroupModelConfig, DynamicFormControlLayout, +} from '@ng-dynamic-forms/core'; + +export const BITSTREAM_METADATA_FORM_GROUP_CONFIG: DynamicFormGroupModelConfig = { + id: 'metadata', + group: [] +}; +export const BITSTREAM_METADATA_FORM_GROUP_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'form-group', + label: 'col-form-label' + }, + grid: { + label: 'col-sm-3' + } +}; + +export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = { + id: 'accessConditions', + groupFactory: null, +}; +export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLayout = { + grid: { + group: 'form-row' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConfig = { + id: 'name', + label: 'submission.sections.upload.form.access-condition-label', + options: [] +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-10' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePickerModelConfig = { + id: 'startDate', + label: 'submission.sections.upload.form.from-label', + placeholder: 'submission.sections.upload.form.from-placeholder', + inline: false, + toggleIcon: 'far fa-calendar-alt', + relation: [ + { + action: 'ENABLE', + connective: 'OR', + when: [] + } + ], + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'submission.sections.upload.form.date-required' + } +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-4' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerModelConfig = { + id: 'endDate', + label: 'submission.sections.upload.form.until-label', + placeholder: 'submission.sections.upload.form.until-placeholder', + inline: false, + toggleIcon: 'far fa-calendar-alt', + relation: [ + { + action: 'ENABLE', + connective: 'OR', + when: [] + } + ], + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'submission.sections.upload.form.date-required' + } +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-4' + } +}; + +export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG: DynamicSelectModelConfig = { + id: 'groupUUID', + label: 'submission.sections.upload.form.group-label', + options: [], + relation: [ + { + action: 'ENABLE', + connective: 'OR', + when: [] + } + ], + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'submission.sections.upload.form.group-required' + } +}; +export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-sm-10' + } +}; diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html new file mode 100644 index 0000000000..ea23340e9a --- /dev/null +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -0,0 +1,68 @@ + +
    +
    + + +
    +
    +
    +

    {{fileName}} ({{fileData?.sizeBytes | dsFileSize}})

    +
    +
    + + + + + + + + + + + +
    +
    + + +
    +
    +
    + + + + + + diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.scss b/src/app/submission/sections/upload/file/section-upload-file.component.scss new file mode 100644 index 0000000000..ce67ef98df --- /dev/null +++ b/src/app/submission/sections/upload/file/section-upload-file.component.scss @@ -0,0 +1,8 @@ +@import '../../../../../styles/variables'; + +.sticky-buttons { + position: sticky; + top: $dropdown-item-padding-x * 3; + z-index: $submission-footer-z-index; + background-color: rgba($white, .97); +} diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.spec.ts b/src/app/submission/sections/upload/file/section-upload-file.component.spec.ts new file mode 100644 index 0000000000..f87aa7d703 --- /dev/null +++ b/src/app/submission/sections/upload/file/section-upload-file.component.spec.ts @@ -0,0 +1,347 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; +import { BrowserModule, By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; + +import { FileService } from '../../../../core/shared/file.service'; +import { FormService } from '../../../../shared/form/form.service'; +import { getMockFormService } from '../../../../shared/mocks/mock-form-service'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { HALEndpointServiceStub } from '../../../../shared/testing/hal-endpoint-service-stub'; +import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { JsonPatchOperationsBuilder } from '../../../../core/json-patch/builder/json-patch-operations-builder'; +import { SubmissionJsonPatchOperationsServiceStub } from '../../../../shared/testing/submission-json-patch-operations-service-stub'; +import { SubmissionJsonPatchOperationsService } from '../../../../core/submission/submission-json-patch-operations.service'; +import { SubmissionSectionUploadFileComponent } from './section-upload-file.component'; +import { SubmissionServiceStub } from '../../../../shared/testing/submission-service-stub'; +import { + mockFileFormData, + mockGroup, + mockSubmissionCollectionId, + mockSubmissionId, + mockSubmissionObject, + mockUploadConfigResponse, + mockUploadFiles +} from '../../../../shared/mocks/mock-submission'; + +import { SubmissionService } from '../../../submission.service'; +import { SectionUploadService } from '../section-upload.service'; +import { createTestComponent } from '../../../../shared/testing/utils'; +import { FileSizePipe } from '../../../../shared/utils/file-size-pipe'; +import { POLICY_DEFAULT_WITH_LIST } from '../section-upload.component'; +import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { getMockSectionUploadService } from '../../../../shared/mocks/mock-section-upload.service'; +import { FormFieldMetadataValueObject } from '../../../../shared/form/builder/models/form-field-metadata-value.model'; +import { Group } from '../../../../core/eperson/models/group.model'; +import { SubmissionSectionUploadFileEditComponent } from './edit/section-upload-file-edit.component'; + +function getMockFileService(): FileService { + return jasmine.createSpyObj('FileService', { + downloadFile: jasmine.createSpy('downloadFile'), + getFileNameFromResponseContentDisposition: jasmine.createSpy('getFileNameFromResponseContentDisposition') + }); +} + +describe('SubmissionSectionUploadFileComponent test suite', () => { + + let comp: SubmissionSectionUploadFileComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let uploadService: any; + let fileService: any; + let formService: any; + let halService: any; + let operationsBuilder: any; + let operationsService: any; + + const submissionJsonPatchOperationsServiceStub = new SubmissionJsonPatchOperationsServiceStub(); + const submissionId = mockSubmissionId; + const sectionId = 'upload'; + const collectionId = mockSubmissionCollectionId; + const availableAccessConditionOptions = mockUploadConfigResponse.accessConditionOptions; + const availableGroupsMap: Map = new Map([ + [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], + [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], + ]); + const collectionPolicyType = POLICY_DEFAULT_WITH_LIST; + const fileIndex = '0'; + const fileName = '123456-test-upload.jpg'; + const fileId = '123456-test-upload'; + const fileData: any = mockUploadFiles[0]; + const pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionId, 'files', fileIndex); + + const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + FileSizePipe, + SubmissionSectionUploadFileComponent, + TestComponent + ], + providers: [ + { provide: FileService, useValue: getMockFileService() }, + { provide: FormService, useValue: getMockFormService() }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') }, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + { provide: SubmissionJsonPatchOperationsService, useValue: submissionJsonPatchOperationsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: SectionUploadService, useValue: getMockSectionUploadService() }, + ChangeDetectorRef, + NgbModal, + SubmissionSectionUploadFileComponent, + SubmissionSectionUploadFileEditComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + 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 SubmissionSectionUploadFileComponent', inject([SubmissionSectionUploadFileComponent], (app: SubmissionSectionUploadFileComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionUploadFileComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + uploadService = TestBed.get(SectionUploadService); + fileService = TestBed.get(FileService); + formService = TestBed.get(FormService); + halService = TestBed.get(HALEndpointService); + operationsBuilder = TestBed.get(JsonPatchOperationsBuilder); + operationsService = TestBed.get(SubmissionJsonPatchOperationsService); + + comp.submissionId = submissionId; + comp.collectionId = collectionId; + comp.sectionId = sectionId; + comp.availableAccessConditionOptions = availableAccessConditionOptions; + comp.availableAccessConditionGroups = availableGroupsMap; + comp.collectionPolicyType = collectionPolicyType; + comp.fileIndex = fileIndex; + comp.fileId = fileId; + comp.fileName = fileName; + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should init component properly', () => { + fixture.detectChanges(); + + expect(comp.formId).toBeDefined(); + expect(compAsAny.pathCombiner).toEqual(pathCombiner); + }); + + it('should init file data properly', () => { + uploadService.getFileData.and.returnValue(observableOf(fileData)); + + comp.ngOnChanges(); + + expect(comp.fileData).toEqual(fileData); + }); + + it('should call deleteFile on delete confirmation', fakeAsync(() => { + spyOn(compAsAny, 'deleteFile'); + comp.fileData = fileData; + + fixture.detectChanges(); + + const modalBtn = fixture.debugElement.query(By.css('.fa-trash ')); + + modalBtn.nativeElement.click(); + fixture.detectChanges(); + + const confirmBtn: any = ((document as any).querySelector('.btn-danger:nth-child(2)')); + confirmBtn.click(); + + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(compAsAny.deleteFile).toHaveBeenCalled(); + }); + })); + + it('should delete file properly', () => { + compAsAny.pathCombiner = pathCombiner; + operationsService.jsonPatchByResourceID.and.returnValue(observableOf({})); + submissionServiceStub.getSubmissionObjectLinkName.and.returnValue('workspaceitems'); + + compAsAny.deleteFile(); + + expect(uploadService.removeUploadedFile).toHaveBeenCalledWith(submissionId, sectionId, fileId); + expect(operationsBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath()); + expect(operationsService.jsonPatchByResourceID).toHaveBeenCalledWith( + 'workspaceitems', + submissionId, + pathCombiner.rootElement, + pathCombiner.subRootElement); + }); + + it('should download Bitstream File properly', fakeAsync(() => { + comp.fileData = fileData; + comp.downloadBitstreamFile(); + + tick(); + + expect(fileService.downloadFile).toHaveBeenCalled() + })); + + it('should save Bitstream File data properly when form is valid', fakeAsync(() => { + compAsAny.fileEditComp = TestBed.get(SubmissionSectionUploadFileEditComponent); + compAsAny.fileEditComp.formRef = {formGroup: null}; + compAsAny.pathCombiner = pathCombiner; + const event = new Event('click', null); + spyOn(comp, 'switchMode'); + formService.validateAllFormFields.and.callFake(() => null); + formService.isValid.and.returnValue(observableOf(true)); + formService.getFormData.and.returnValue(observableOf(mockFileFormData)); + + const response = [ + Object.assign(mockSubmissionObject, { + sections: { + upload: { + files: mockUploadFiles + } + } + }) + ]; + operationsService.jsonPatchByResourceID.and.returnValue(observableOf(response)); + + const accessConditionsToSave = [ + { name: 'openaccess', groupUUID: '123456-g' }, + { name: 'lease', endDate: '2019-01-16T00:00:00Z', groupUUID: '123456-g' }, + { name: 'embargo', startDate: '2019-01-16T00:00:00Z', groupUUID: '123456-g' } + ]; + comp.saveBitstreamData(event); + tick(); + + let path = 'metadata/dc.title'; + expect(operationsBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath(path), + mockFileFormData.metadata['dc.title'], + true + ); + + path = 'metadata/dc.description'; + expect(operationsBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath(path), + mockFileFormData.metadata['dc.description'], + true + ); + + path = 'accessConditions'; + expect(operationsBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath(path), + accessConditionsToSave, + true + ); + + expect(comp.switchMode).toHaveBeenCalled(); + expect(uploadService.updateFileData).toHaveBeenCalledWith(submissionId, sectionId, mockUploadFiles[0].uuid, mockUploadFiles[0]); + + })); + + it('should not save Bitstream File data properly when form is not valid', fakeAsync(() => { + compAsAny.fileEditComp = TestBed.get(SubmissionSectionUploadFileEditComponent); + compAsAny.fileEditComp.formRef = {formGroup: null}; + compAsAny.pathCombiner = pathCombiner; + const event = new Event('click', null); + spyOn(comp, 'switchMode'); + formService.validateAllFormFields.and.callFake(() => null); + formService.isValid.and.returnValue(observableOf(false)); + + expect(comp.switchMode).not.toHaveBeenCalled(); + expect(uploadService.updateFileData).not.toHaveBeenCalled(); + + })); + + it('should retrieve Value From Field properly', () => { + let field; + expect(compAsAny.retrieveValueFromField(field)).toBeUndefined(); + + field = new FormFieldMetadataValueObject('test'); + expect(compAsAny.retrieveValueFromField(field)).toBe('test'); + + field = [new FormFieldMetadataValueObject('test')]; + expect(compAsAny.retrieveValueFromField(field)).toBe('test'); + }); + + it('should switch read mode', () => { + comp.readMode = false; + + comp.switchMode(); + expect(comp.readMode).toBeTruthy(); + + comp.switchMode(); + + expect(comp.readMode).toBeFalsy(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + availableGroups; + availableAccessConditionOptions; + collectionId = mockSubmissionCollectionId; + collectionPolicyType; + configMetadataForm$; + fileIndexes = []; + fileList = []; + fileNames = []; + sectionId = 'upload'; + submissionId = mockSubmissionId; +} diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.ts b/src/app/submission/sections/upload/file/section-upload-file.component.ts new file mode 100644 index 0000000000..9923c358e7 --- /dev/null +++ b/src/app/submission/sections/upload/file/section-upload-file.component.ts @@ -0,0 +1,344 @@ +import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'; + +import { BehaviorSubject, Subscription } from 'rxjs'; +import { filter, first, flatMap, take } from 'rxjs/operators'; +import { DynamicFormControlModel, } from '@ng-dynamic-forms/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { SectionUploadService } from '../section-upload.service'; +import { isNotEmpty, isNotNull, isNotUndefined } from '../../../../shared/empty.util'; +import { FormService } from '../../../../shared/form/form.service'; +import { JsonPatchOperationsBuilder } from '../../../../core/json-patch/builder/json-patch-operations-builder'; +import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { WorkspaceitemSectionUploadFileObject } from '../../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { SubmissionFormsModel } from '../../../../core/config/models/config-submission-forms.model'; +import { deleteProperty } from '../../../../shared/object.util'; +import { dateToISOFormat } from '../../../../shared/date.util'; +import { SubmissionService } from '../../../submission.service'; +import { FileService } from '../../../../core/shared/file.service'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { SubmissionJsonPatchOperationsService } from '../../../../core/submission/submission-json-patch-operations.service'; +import { SubmissionObject } from '../../../../core/submission/models/submission-object.model'; +import { WorkspaceitemSectionUploadObject } from '../../../../core/submission/models/workspaceitem-section-upload.model'; +import { SubmissionSectionUploadFileEditComponent } from './edit/section-upload-file-edit.component'; +import { Group } from '../../../../core/eperson/models/group.model'; + +/** + * This component represents a single bitstream contained in the submission + */ +@Component({ + selector: 'ds-submission-upload-section-file', + styleUrls: ['./section-upload-file.component.scss'], + templateUrl: './section-upload-file.component.html', +}) +export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit { + + /** + * The list of available access condition + * @type {Array} + */ + @Input() availableAccessConditionOptions: any[]; + + /** + * The list of available groups for an access condition + * @type {Array} + */ + @Input() availableAccessConditionGroups: Map; + + /** + * The submission id + * @type {string} + */ + @Input() collectionId: string; + + /** + * Define if collection access conditions policy type : + * POLICY_DEFAULT_NO_LIST : is not possible to define additional access group/s for the single file + * POLICY_DEFAULT_WITH_LIST : is possible to define additional access group/s for the single file + * @type {number} + */ + @Input() collectionPolicyType: number; + + /** + * The configuration for the bitstream's metadata form + * @type {SubmissionFormsModel} + */ + @Input() configMetadataForm: SubmissionFormsModel; + + /** + * The bitstream id + * @type {string} + */ + @Input() fileId: string; + + /** + * The bitstream array key + * @type {string} + */ + @Input() fileIndex: string; + + /** + * The bitstream id + * @type {string} + */ + @Input() fileName: string; + + /** + * The section id + * @type {string} + */ + @Input() sectionId: string; + + /** + * The submission id + * @type {string} + */ + @Input() submissionId: string; + + /** + * The bitstream's metadata data + * @type {WorkspaceitemSectionUploadFileObject} + */ + public fileData: WorkspaceitemSectionUploadFileObject; + + /** + * The form id + * @type {string} + */ + public formId: string; + + /** + * A boolean representing if to show bitstream edit form + * @type {boolean} + */ + public readMode: boolean; + + /** + * The form model + * @type {DynamicFormControlModel[]} + */ + public formModel: DynamicFormControlModel[]; + + /** + * A boolean representing if a submission delete operation is pending + * @type {BehaviorSubject} + */ + public processingDelete$ = new BehaviorSubject(false); + + /** + * The [JsonPatchOperationPathCombiner] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subscriptions: Subscription[] = []; + + /** + * The [[SubmissionSectionUploadFileEditComponent]] reference + * @type {SubmissionSectionUploadFileEditComponent} + */ + @ViewChild(SubmissionSectionUploadFileEditComponent) fileEditComp: SubmissionSectionUploadFileEditComponent; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} cdr + * @param {FileService} fileService + * @param {FormService} formService + * @param {HALEndpointService} halService + * @param {NgbModal} modalService + * @param {JsonPatchOperationsBuilder} operationsBuilder + * @param {SubmissionJsonPatchOperationsService} operationsService + * @param {SubmissionService} submissionService + * @param {SectionUploadService} uploadService + */ + constructor(private cdr: ChangeDetectorRef, + private fileService: FileService, + private formService: FormService, + private halService: HALEndpointService, + private modalService: NgbModal, + private operationsBuilder: JsonPatchOperationsBuilder, + private operationsService: SubmissionJsonPatchOperationsService, + private submissionService: SubmissionService, + private uploadService: SectionUploadService) { + this.readMode = true; + } + + /** + * Retrieve bitstream's metadata + */ + ngOnChanges() { + if (this.availableAccessConditionOptions && this.availableAccessConditionGroups) { + // Retrieve file state + this.subscriptions.push( + this.uploadService + .getFileData(this.submissionId, this.sectionId, this.fileId).pipe( + filter((bitstream) => isNotUndefined(bitstream))) + .subscribe((bitstream) => { + this.fileData = bitstream; + } + ) + ); + } + } + + /** + * Initialize instance variables + */ + ngOnInit() { + this.formId = this.formService.getUniqueId(this.fileId); + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionId, 'files', this.fileIndex); + } + + /** + * Delete bitstream from submission + */ + protected deleteFile() { + this.operationsBuilder.remove(this.pathCombiner.getPath()); + this.subscriptions.push(this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + this.pathCombiner.rootElement, + this.pathCombiner.subRootElement) + .subscribe(() => { + this.uploadService.removeUploadedFile(this.submissionId, this.sectionId, this.fileId); + this.processingDelete$.next(false); + })); + } + + /** + * Show confirmation dialog for delete + */ + public confirmDelete(content) { + this.modalService.open(content).result.then( + (result) => { + if (result === 'ok') { + this.processingDelete$.next(true); + this.deleteFile(); + } + } + ); + } + + /** + * Perform bitstream download + */ + public downloadBitstreamFile() { + this.halService.getEndpoint('bitstreams').pipe( + first()) + .subscribe((url) => { + const fileUrl = `${url}/${this.fileData.uuid}/content`; + this.fileService.downloadFile(fileUrl); + }); + } + + /** + * Save bitstream metadata + * + * @param event + * the click event emitted + */ + public saveBitstreamData(event) { + event.preventDefault(); + + // validate form + this.formService.validateAllFormFields(this.fileEditComp.formRef.formGroup); + this.subscriptions.push(this.formService.isValid(this.formId).pipe( + take(1), + filter((isValid) => isValid), + flatMap(() => this.formService.getFormData(this.formId)), + take(1), + flatMap((formData: any) => { + // collect bitstream metadata + Object.keys((formData.metadata)) + .filter((key) => isNotEmpty(formData.metadata[key])) + .forEach((key) => { + const metadataKey = key.replace(/_/g, '.'); + const path = `metadata/${metadataKey}`; + this.operationsBuilder.add(this.pathCombiner.getPath(path), formData.metadata[key], true); + }); + const accessConditionsToSave = []; + formData.accessConditions + .filter((accessCondition) => isNotEmpty(accessCondition)) + .forEach((accessCondition) => { + let accessConditionOpt; + + this.availableAccessConditionOptions + .filter((element) => isNotNull(accessCondition.name) && element.name === accessCondition.name[0].value) + .forEach((element) => accessConditionOpt = element); + + if (accessConditionOpt) { + + if (accessConditionOpt.hasStartDate !== true && accessConditionOpt.hasEndDate !== true) { + accessConditionOpt = deleteProperty(accessConditionOpt, 'hasStartDate'); + + accessConditionOpt = deleteProperty(accessConditionOpt, 'hasEndDate'); + accessConditionsToSave.push(accessConditionOpt); + } else { + accessConditionOpt = Object.assign({}, accessCondition); + accessConditionOpt.name = this.retrieveValueFromField(accessCondition.name); + accessConditionOpt.groupUUID = this.retrieveValueFromField(accessCondition.groupUUID); + if (accessCondition.startDate) { + const startDate = this.retrieveValueFromField(accessCondition.startDate); + accessConditionOpt.startDate = dateToISOFormat(startDate); + accessConditionOpt = deleteProperty(accessConditionOpt, 'endDate'); + } + if (accessCondition.endDate) { + const endDate = this.retrieveValueFromField(accessCondition.endDate); + accessConditionOpt.endDate = dateToISOFormat(endDate); + accessConditionOpt = deleteProperty(accessConditionOpt, 'startDate'); + } + accessConditionsToSave.push(accessConditionOpt); + } + } + }); + + if (isNotEmpty(accessConditionsToSave)) { + this.operationsBuilder.add(this.pathCombiner.getPath('accessConditions'), accessConditionsToSave, true); + } + + // dispatch a PATCH request to save metadata + return this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + this.submissionId, + this.pathCombiner.rootElement, + this.pathCombiner.subRootElement) + }) + ).subscribe((result: SubmissionObject[]) => { + if (result[0].sections.upload) { + Object.keys((result[0].sections.upload as WorkspaceitemSectionUploadObject).files) + .filter((key) => (result[0].sections.upload as WorkspaceitemSectionUploadObject).files[key].uuid === this.fileId) + .forEach((key) => this.uploadService.updateFileData( + this.submissionId, + this.sectionId, + this.fileId, + (result[0].sections.upload as WorkspaceitemSectionUploadObject).files[key])); + } + this.switchMode(); + })); + } + + /** + * Retrieve field value + * + * @param field + * the specified field object + */ + private retrieveValueFromField(field: any) { + const temp = Array.isArray(field) ? field[0] : field; + return (temp) ? temp.value : undefined; + } + + /** + * Switch from edit form to metadata view + */ + public switchMode() { + this.readMode = !this.readMode; + this.cdr.detectChanges(); + } + +} diff --git a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.html b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.html new file mode 100644 index 0000000000..65b4b6379b --- /dev/null +++ b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.html @@ -0,0 +1,29 @@ +
    + + + +
    + {{entry.value}} +
    +
    + +
    + {{'submission.sections.upload.no-entry' | translate}} {{fileTitleKey}} +
    +
    + +
    + + + {{entry.value | dsTruncate:[150]}} + + + {{'submission.sections.upload.no-entry' | translate}} {{fileDescrKey}} + + + +
    + + + +
    diff --git a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.spec.ts b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.spec.ts new file mode 100644 index 0000000000..87b025e6a9 --- /dev/null +++ b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.spec.ts @@ -0,0 +1,100 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { createTestComponent } from '../../../../../shared/testing/utils'; +import { mockUploadFiles } from '../../../../../shared/mocks/mock-submission'; +import { FormComponent } from '../../../../../shared/form/form.component'; +import { SubmissionSectionUploadFileViewComponent } from './section-upload-file-view.component'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { Metadata } from '../../../../../core/shared/metadata.utils'; + +describe('SubmissionSectionUploadFileViewComponent test suite', () => { + + let comp: SubmissionSectionUploadFileViewComponent; + let compAsAny: any; + let fixture: ComponentFixture; + + const fileData: any = mockUploadFiles[0]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot() + ], + declarations: [ + TruncatePipe, + FormComponent, + SubmissionSectionUploadFileViewComponent, + TestComponent + ], + providers: [ + SubmissionSectionUploadFileViewComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + 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 SubmissionSectionUploadFileViewComponent', inject([SubmissionSectionUploadFileViewComponent], (app: SubmissionSectionUploadFileViewComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionUploadFileViewComponent); + comp = fixture.componentInstance; + compAsAny = comp; + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should init metadata array properly', () => { + comp.fileData = fileData; + const expectMetadataMap = { + [comp.fileTitleKey]: Metadata.all(fileData.metadata, 'dc.title'), + [comp.fileDescrKey]: [], + }; + + fixture.detectChanges(); + + expect(comp.metadata).toEqual(expectMetadataMap); + + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + fileData; +} diff --git a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts new file mode 100644 index 0000000000..bb2fea20f8 --- /dev/null +++ b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts @@ -0,0 +1,62 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { isNotEmpty } from '../../../../../shared/empty.util'; +import { Metadata } from '../../../../../core/shared/metadata.utils'; +import { MetadataMap, MetadataValue } from '../../../../../core/shared/metadata.models'; + +/** + * This component allow to show bitstream's metadata + */ +@Component({ + selector: 'ds-submission-section-upload-file-view', + templateUrl: './section-upload-file-view.component.html', +}) +export class SubmissionSectionUploadFileViewComponent implements OnInit { + + /** + * The bitstream's metadata data + * @type {WorkspaceitemSectionUploadFileObject} + */ + @Input() fileData: WorkspaceitemSectionUploadFileObject; + + /** + * The [[MetadataMap]] object + * @type {MetadataMap} + */ + public metadata: MetadataMap = Object.create({}); + + /** + * The bitstream's title key + * @type {string} + */ + public fileTitleKey = 'Title'; + + /** + * The bitstream's description key + * @type {string} + */ + public fileDescrKey = 'Description'; + + /** + * Initialize instance variables + */ + ngOnInit() { + if (isNotEmpty(this.fileData.metadata)) { + this.metadata[this.fileTitleKey] = Metadata.all(this.fileData.metadata, 'dc.title'); + this.metadata[this.fileDescrKey] = Metadata.all(this.fileData.metadata, 'dc.description'); + } + } + + /** + * Gets all matching metadata in the map(s) + * + * @param metadataKey + * The metadata key(s) in scope + * @returns {MetadataValue[]} + * The matching values + */ + getAllMetadataValue(metadataKey: string): MetadataValue[] { + return Metadata.all(this.metadata, metadataKey); + } +} diff --git a/src/app/submission/sections/upload/section-upload.component.html b/src/app/submission/sections/upload/section-upload.component.html new file mode 100644 index 0000000000..d63bc1b7d6 --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.component.html @@ -0,0 +1,47 @@ + + + +
    +
    +

    {{'submission.sections.upload.no-file-uploaded' | translate}}

    +
    +
    +
    + + + +
    +
    + + + {{ 'submission.sections.upload.header.policy.default.nolist' | translate:{ "collectionName": collectionName } }} + + + {{ 'submission.sections.upload.header.policy.default.withlist' | translate:{ "collectionName": collectionName } }} + + + + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    diff --git a/src/app/submission/sections/upload/section-upload.component.scss b/src/app/submission/sections/upload/section-upload.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/upload/section-upload.component.spec.ts b/src/app/submission/sections/upload/section-upload.component.spec.ts new file mode 100644 index 0000000000..be8f096964 --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.component.spec.ts @@ -0,0 +1,292 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { of as observableOf } from 'rxjs'; + +import { createTestComponent } from '../../../shared/testing/utils'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; +import { SectionsService } from '../sections.service'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service-stub'; +import { SubmissionFormsConfigService } from '../../../core/config/submission-forms-config.service'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsType } from '../sections-type'; +import { + mockGroup, + mockSubmissionCollectionId, + mockSubmissionId, + mockSubmissionState, + mockUploadConfigResponse, + mockUploadFiles +} from '../../../shared/mocks/mock-submission'; +import { BrowserModule } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { SubmissionUploadsConfigService } from '../../../core/config/submission-uploads-config.service'; +import { SectionUploadService } from './section-upload.service'; +import { SubmissionSectionUploadComponent } from './section-upload.component'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { GroupEpersonService } from '../../../core/eperson/group-eperson.service'; +import { cold, hot } from 'jasmine-marbles'; +import { Collection } from '../../../core/shared/collection.model'; +import { ResourcePolicy } from '../../../core/shared/resource-policy.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ConfigData } from '../../../core/config/config-data'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { Group } from '../../../core/eperson/models/group.model'; +import { getMockSectionUploadService } from '../../../shared/mocks/mock-section-upload.service'; + +function getMockSubmissionUploadsConfigService(): SubmissionFormsConfigService { + return jasmine.createSpyObj('SubmissionUploadsConfigService', { + getConfigAll: jasmine.createSpy('getConfigAll'), + getConfigByHref: jasmine.createSpy('getConfigByHref'), + getConfigByName: jasmine.createSpy('getConfigByName'), + getConfigBySearch: jasmine.createSpy('getConfigBySearch') + }); +} + +function getMockCollectionDataService(): CollectionDataService { + return jasmine.createSpyObj('CollectionDataService', { + findById: jasmine.createSpy('findById'), + findByHref: jasmine.createSpy('findByHref') + }); +} + +function getMockGroupEpersonService(): GroupEpersonService { + return jasmine.createSpyObj('GroupEpersonService', { + findById: jasmine.createSpy('findById'), + + }); +} + +const sectionObject: SectionDataObject = { + config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', + mandatory: true, + data: { + files: [] + }, + errors: [], + header: 'submit.progressbar.describe.upload', + id: 'upload', + sectionType: SectionsType.Upload +}; + +describe('SubmissionSectionUploadComponent test suite', () => { + + let comp: SubmissionSectionUploadComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let sectionsServiceStub: SectionsServiceStub; + let collectionDataService: any; + let groupService: any; + let uploadsConfigService: any; + let bitstreamService: any; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const submissionState = Object.assign({}, mockSubmissionState[mockSubmissionId]); + const mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + _links: { + defaultAccessConditions: collectionId + '/defaultAccessConditions' + } + }); + const mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { + name: null, + groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', + id: 20, + uuid: 'resource-policy-20' + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + TranslateModule.forRoot() + ], + declarations: [ + SubmissionSectionUploadComponent, + TestComponent + ], + providers: [ + { provide: CollectionDataService, useValue: getMockCollectionDataService() }, + { provide: GroupEpersonService, useValue: getMockGroupEpersonService() }, + { provide: SubmissionUploadsConfigService, useValue: getMockSubmissionUploadsConfigService() }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: SectionUploadService, useValue: getMockSectionUploadService() }, + { provide: 'sectionDataProvider', useValue: sectionObject }, + { provide: 'submissionIdProvider', useValue: submissionId }, + ChangeDetectorRef, + SubmissionSectionUploadComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + 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 SubmissionSectionUploadComponent', inject([SubmissionSectionUploadComponent], (app: SubmissionSectionUploadComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionUploadComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.get(SubmissionService); + sectionsServiceStub = TestBed.get(SectionsService); + collectionDataService = TestBed.get(CollectionDataService); + groupService = TestBed.get(GroupEpersonService); + uploadsConfigService = TestBed.get(SubmissionUploadsConfigService); + bitstreamService = TestBed.get(SectionUploadService); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should init component properly', () => { + + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(observableOf( + new RemoteData(false, false, true, + undefined, mockCollection))); + + collectionDataService.findByHref.and.returnValue(observableOf( + new RemoteData(false, false, true, + undefined, mockDefaultAccessCondition) + )); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponse as any) + )); + + groupService.findById.and.returnValues( + observableOf(new RemoteData(false, false, true, + undefined, Object.assign(new Group(), mockGroup))), + observableOf(new RemoteData(false, false, true, + undefined, Object.assign(new Group(), mockGroup))) + ); + + bitstreamService.getUploadedFileList.and.returnValue(observableOf([])); + + comp.onSectionInit(); + + const expectedGroupsMap = new Map([ + [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], + [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], + ]); + + expect(comp.collectionId).toBe(collectionId); + expect(comp.collectionName).toBe(mockCollection.name); + expect(comp.availableAccessConditionOptions.length).toBe(4); + expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(compAsAny.subs.length).toBe(2); + expect(compAsAny.availableGroups.size).toBe(2); + expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); + expect(compAsAny.fileList).toEqual([]); + expect(compAsAny.fileIndexes).toEqual([]); + expect(compAsAny.fileNames).toEqual([]); + + }); + + it('should init file list properly', () => { + + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(observableOf( + new RemoteData(false, false, true, + undefined, mockCollection))); + + collectionDataService.findByHref.and.returnValue(observableOf( + new RemoteData(false, false, true, + undefined, mockDefaultAccessCondition) + )); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponse as any) + )); + + groupService.findById.and.returnValues( + observableOf(new RemoteData(false, false, true, + undefined, Object.assign(new Group(), mockGroup))), + observableOf(new RemoteData(false, false, true, + undefined, Object.assign(new Group(), mockGroup))) + ); + + bitstreamService.getUploadedFileList.and.returnValue(observableOf(mockUploadFiles)); + + comp.onSectionInit(); + + const expectedGroupsMap = new Map([ + [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], + [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], + ]); + + expect(comp.collectionId).toBe(collectionId); + expect(comp.collectionName).toBe(mockCollection.name); + expect(comp.availableAccessConditionOptions.length).toBe(4); + expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(compAsAny.subs.length).toBe(2); + expect(compAsAny.availableGroups.size).toBe(2); + expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); + expect(compAsAny.fileList).toEqual(mockUploadFiles); + expect(compAsAny.fileIndexes).toEqual(['123456-test-upload']); + expect(compAsAny.fileNames).toEqual(['123456-test-upload.jpg']); + + }); + + it('should the properly section status', () => { + bitstreamService.getUploadedFileList.and.returnValue(hot('-a-b', { + a: [], + b: mockUploadFiles + })); + + expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { + c: false, + d: true + })); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts new file mode 100644 index 0000000000..3a79a670ad --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -0,0 +1,287 @@ +import { ChangeDetectorRef, Component, Inject } from '@angular/core'; + +import { combineLatest, Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, reduce, take, tap } from 'rxjs/operators'; + +import { SectionModelComponent } from '../models/section.model'; +import { hasValue, isNotEmpty, isNotUndefined, isUndefined } from '../../../shared/empty.util'; +import { SectionUploadService } from './section-upload.service'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { GroupEpersonService } from '../../../core/eperson/group-eperson.service'; +import { SubmissionUploadsConfigService } from '../../../core/config/submission-uploads-config.service'; +import { SubmissionUploadsModel } from '../../../core/config/models/config-submission-uploads.model'; +import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; +import { SectionsType } from '../sections-type'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { SubmissionObjectEntry } from '../../objects/submission-objects.reducer'; +import { AlertType } from '../../../shared/alert/aletr-type'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Group } from '../../../core/eperson/models/group.model'; +import { SectionsService } from '../sections.service'; +import { SubmissionService } from '../../submission.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { ResourcePolicy } from '../../../core/shared/resource-policy.model'; +import { AccessConditionOption } from '../../../core/config/models/config-access-condition-option.model'; +import { PaginatedList } from '../../../core/data/paginated-list'; + +export const POLICY_DEFAULT_NO_LIST = 1; // Banner1 +export const POLICY_DEFAULT_WITH_LIST = 2; // Banner2 + +export interface AccessConditionGroupsMapEntry { + accessCondition: string; + groups: Group[] +} + +/** + * This component represents a section that contains submission's bitstreams + */ +@Component({ + selector: 'ds-submission-section-upload', + styleUrls: ['./section-upload.component.scss'], + templateUrl: './section-upload.component.html', +}) +@renderSectionFor(SectionsType.Upload) +export class SubmissionSectionUploadComponent extends SectionModelComponent { + + /** + * The AlertType enumeration + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * The array containing the keys of file list array + * @type {Array} + */ + public fileIndexes: string[] = []; + + /** + * The file list + * @type {Array} + */ + public fileList: any[] = []; + + /** + * The array containing the name of the files + * @type {Array} + */ + public fileNames: string[] = []; + + /** + * The collection name this submission belonging to + * @type {string} + */ + public collectionName: string; + + /** + * Default access conditions of this collection + * @type {Array} + */ + public collectionDefaultAccessConditions: any[] = []; + + /** + * Define if collection access conditions policy type : + * POLICY_DEFAULT_NO_LIST : is not possible to define additional access group/s for the single file + * POLICY_DEFAULT_WITH_LIST : is possible to define additional access group/s for the single file + * @type {number} + */ + public collectionPolicyType: number; + + /** + * The configuration for the bitstream's metadata form + */ + public configMetadataForm$: Observable; + + /** + * List of available access conditions that could be setted to files + */ + public availableAccessConditionOptions: AccessConditionOption[]; // List of accessConditions that an user can select + + /** + * List of Groups available for every access condition + */ + protected availableGroups: Map; // Groups for any policy + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {SectionUploadService} bitstreamService + * @param {ChangeDetectorRef} changeDetectorRef + * @param {CollectionDataService} collectionDataService + * @param {GroupEpersonService} groupService + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {SubmissionUploadsConfigService} uploadsConfigService + * @param {SectionDataObject} injectedSectionData + * @param {string} injectedSubmissionId + */ + constructor(private bitstreamService: SectionUploadService, + private changeDetectorRef: ChangeDetectorRef, + private collectionDataService: CollectionDataService, + private groupService: GroupEpersonService, + protected sectionService: SectionsService, + private submissionService: SubmissionService, + private uploadsConfigService: SubmissionUploadsConfigService, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(undefined, injectedSectionData, injectedSubmissionId); + } + + /** + * Initialize all instance variables and retrieve collection default access conditions + */ + onSectionInit() { + const config$ = this.uploadsConfigService.getConfigByHref(this.sectionData.config).pipe( + map((config) => config.payload)); + + // retrieve configuration for the bitstream's metadata form + this.configMetadataForm$ = config$.pipe( + take(1), + map((config: SubmissionUploadsModel) => config.metadata)); + + this.subs.push( + this.submissionService.getSubmissionObject(this.submissionId).pipe( + filter((submissionObject: SubmissionObjectEntry) => isNotUndefined(submissionObject) && !submissionObject.isLoading), + filter((submissionObject: SubmissionObjectEntry) => isUndefined(this.collectionId) || this.collectionId !== submissionObject.collection), + tap((submissionObject: SubmissionObjectEntry) => this.collectionId = submissionObject.collection), + flatMap((submissionObject: SubmissionObjectEntry) => this.collectionDataService.findById(submissionObject.collection)), + find((rd: RemoteData) => isNotUndefined((rd.payload))), + tap((collectionRemoteData: RemoteData) => this.collectionName = collectionRemoteData.payload.name), + flatMap((collectionRemoteData: RemoteData) => { + return this.collectionDataService.findByHref( + (collectionRemoteData.payload as any)._links.defaultAccessConditions + ); + }), + find((defaultAccessConditionsRemoteData: RemoteData) => + defaultAccessConditionsRemoteData.hasSucceeded), + tap((defaultAccessConditionsRemoteData: RemoteData) => { + if (isNotEmpty(defaultAccessConditionsRemoteData.payload)) { + this.collectionDefaultAccessConditions = Array.isArray(defaultAccessConditionsRemoteData.payload) + ? defaultAccessConditionsRemoteData.payload : [defaultAccessConditionsRemoteData.payload]; + } + }), + flatMap(() => config$), + take(1), + flatMap((config: SubmissionUploadsModel) => { + this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : []; + + this.collectionPolicyType = this.availableAccessConditionOptions.length > 0 + ? POLICY_DEFAULT_WITH_LIST + : POLICY_DEFAULT_NO_LIST; + + this.availableGroups = new Map(); + const mapGroups$: Array> = []; + // Retrieve Groups for accessCondition Policies + this.availableAccessConditionOptions.forEach((accessCondition: AccessConditionOption) => { + if (accessCondition.hasEndDate === true || accessCondition.hasStartDate === true) { + if (accessCondition.groupUUID) { + mapGroups$.push( + this.groupService.findById(accessCondition.groupUUID).pipe( + find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded), + map((rd: RemoteData) => ({ + accessCondition: accessCondition.name, + groups: [rd.payload] + } as AccessConditionGroupsMapEntry))) + ); + } else if (accessCondition.selectGroupUUID) { + mapGroups$.push( + this.groupService.findById(accessCondition.selectGroupUUID).pipe( + find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded), + flatMap((group: RemoteData) => group.payload.groups), + find((rd: RemoteData>) => !rd.isResponsePending && rd.hasSucceeded), + map((rd: RemoteData>) => ({ + accessCondition: accessCondition.name, + groups: rd.payload.page + } as AccessConditionGroupsMapEntry)) + )); + } + } + }); + return mapGroups$; + }), + flatMap((entry) => entry), + reduce((acc: any[], entry: AccessConditionGroupsMapEntry) => { + acc.push(entry); + return acc; + }, []), + ).subscribe((entries: AccessConditionGroupsMapEntry[]) => { + entries.forEach((entry: AccessConditionGroupsMapEntry) => { + this.availableGroups.set(entry.accessCondition, entry.groups); + }); + this.changeDetectorRef.detectChanges(); + }), + + // retrieve submission's bitstreams from state + combineLatest(this.configMetadataForm$, + this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id)).pipe( + filter(([configMetadataForm, fileList]: [SubmissionFormsModel, any[]]) => { + return isNotEmpty(configMetadataForm) && isNotUndefined(fileList) + }), + distinctUntilChanged()) + .subscribe(([configMetadataForm, fileList]: [SubmissionFormsModel, any[]]) => { + this.fileList = []; + this.fileIndexes = []; + this.fileNames = []; + this.changeDetectorRef.detectChanges(); + if (isNotUndefined(fileList) && fileList.length > 0) { + fileList.forEach((file) => { + this.fileList.push(file); + this.fileIndexes.push(file.uuid); + this.fileNames.push(this.getFileName(configMetadataForm, file)); + }); + } + + this.changeDetectorRef.detectChanges(); + } + ) + ); + } + + /** + * Return file name from metadata + * + * @param configMetadataForm + * the bitstream's form configuration + * @param fileData + * the file metadata + */ + private getFileName(configMetadataForm: SubmissionFormsModel, fileData: any): string { + const metadataName: string = configMetadataForm.rows[0].fields[0].selectableMetadata[0].metadata; + let title: string; + if (isNotEmpty(fileData.metadata) && isNotEmpty(fileData.metadata[metadataName])) { + title = fileData.metadata[metadataName][0].display; + } else { + title = fileData.uuid; + } + + return title; + } + + /** + * Get section status + * + * @return Observable + * the section status + */ + protected getSectionStatus(): Observable { + return this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id).pipe( + map((fileList: any[]) => (isNotUndefined(fileList) && fileList.length > 0))); + } + + /** + * Method provided by Angular. Invoked when the instance is destroyed. + */ + onSectionDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/submission/sections/upload/section-upload.service.ts b/src/app/submission/sections/upload/section-upload.service.ts new file mode 100644 index 0000000000..a851fa9daf --- /dev/null +++ b/src/app/submission/sections/upload/section-upload.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; + +import { SubmissionState } from '../../submission.reducers'; +import { + DeleteUploadedFileAction, + EditFileDataAction, + NewUploadedFileAction +} from '../../objects/submission-objects.actions'; +import { submissionUploadedFileFromUuidSelector, submissionUploadedFilesFromIdSelector } from '../../selectors'; +import { isUndefined } from '../../../shared/empty.util'; +import { WorkspaceitemSectionUploadFileObject } from '../../../core/submission/models/workspaceitem-section-upload-file.model'; + +/** + * A service that provides methods to handle submission's bitstream state. + */ +@Injectable() +export class SectionUploadService { + + /** + * Initialize service variables + * + * @param {Store} store + */ + constructor(private store: Store) {} + + /** + * Return submission's bitstream list from state + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @returns {Array} + * Returns submission's bitstream list + */ + public getUploadedFileList(submissionId: string, sectionId: string): Observable { + return this.store.select(submissionUploadedFilesFromIdSelector(submissionId, sectionId)).pipe( + map((state) => state), + distinctUntilChanged()); + } + + /** + * Return bitstream's metadata + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param fileUUID + * The bitstream UUID + * @returns {Observable} + * Emits bitstream's metadata + */ + public getFileData(submissionId: string, sectionId: string, fileUUID: string): Observable { + return this.store.select(submissionUploadedFilesFromIdSelector(submissionId, sectionId)).pipe( + filter((state) => !isUndefined(state)), + map((state) => { + let fileState; + Object.keys(state) + .filter((key) => state[key].uuid === fileUUID) + .forEach((key) => fileState = state[key]); + return fileState; + }), + distinctUntilChanged()); + } + + /** + * Return bitstream's default policies + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param fileUUID + * The bitstream UUID + * @returns {Observable} + * Emits bitstream's default policies + */ + public getDefaultPolicies(submissionId: string, sectionId: string, fileUUID: string): Observable { + return this.store.select(submissionUploadedFileFromUuidSelector(submissionId, sectionId, fileUUID)).pipe( + map((state) => state), + distinctUntilChanged()); + } + + /** + * Add a new bitstream to the state + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param fileUUID + * The bitstream UUID + * @param data + * The [[WorkspaceitemSectionUploadFileObject]] object + */ + public addUploadedFile(submissionId: string, sectionId: string, fileUUID: string, data: WorkspaceitemSectionUploadFileObject) { + this.store.dispatch( + new NewUploadedFileAction(submissionId, sectionId, fileUUID, data) + ); + } + + /** + * Update bitstream metadata into the state + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param fileUUID + * The bitstream UUID + * @param data + * The [[WorkspaceitemSectionUploadFileObject]] object + */ + public updateFileData(submissionId: string, sectionId: string, fileUUID: string, data: WorkspaceitemSectionUploadFileObject) { + this.store.dispatch( + new EditFileDataAction(submissionId, sectionId, fileUUID, data) + ); + } + + /** + * Remove bitstream from the state + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param fileUUID + * The bitstream UUID + */ + public removeUploadedFile(submissionId: string, sectionId: string, fileUUID: string) { + this.store.dispatch( + new DeleteUploadedFileAction(submissionId, sectionId, fileUUID) + ); + } +} diff --git a/src/app/submission/selectors.ts b/src/app/submission/selectors.ts new file mode 100644 index 0000000000..51c960b537 --- /dev/null +++ b/src/app/submission/selectors.ts @@ -0,0 +1,65 @@ +import { createSelector, MemoizedSelector, Selector } from '@ngrx/store'; + +import { hasValue } from '../shared/empty.util'; +import { submissionSelector, SubmissionState } from './submission.reducers'; +import { SubmissionObjectEntry, SubmissionSectionObject } from './objects/submission-objects.reducer'; + +/** + * Export a function to return a subset of the state by key + */ +export function keySelector(parentSelector: Selector, subState: string, key: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState][key]; + } else { + return undefined; + } + }); +} + +/** + * Export a function to return a subset of the state + */ +export function subStateSelector(parentSelector: Selector, subState: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState]; + } else { + return undefined; + } + }); +} + +export function submissionObjectFromIdSelector(submissionId: string): MemoizedSelector { + return keySelector(submissionSelector, 'objects', submissionId); +} + +export function submissionObjectSectionsFromIdSelector(submissionId: string): MemoizedSelector { + const submissionObjectSelector = submissionObjectFromIdSelector(submissionId); + return subStateSelector(submissionObjectSelector, 'sections'); +} + +export function submissionUploadedFilesFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const sectionDataSelector = submissionSectionDataFromIdSelector(submissionId, sectionId); + return subStateSelector(sectionDataSelector, 'files'); +} + +export function submissionUploadedFileFromUuidSelector(submissionId: string, sectionId: string, uuid: string): MemoizedSelector { + const filesSelector = submissionSectionDataFromIdSelector(submissionId, sectionId); + return keySelector(filesSelector, 'files', uuid); +} + +export function submissionSectionFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const submissionIdSelector = submissionObjectFromIdSelector(submissionId); + return keySelector(submissionIdSelector, 'sections', sectionId); +} + +export function submissionSectionDataFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId); + return subStateSelector(submissionIdSelector, 'data'); +} + +export function submissionSectionErrorsFromIdSelector(submissionId: string, sectionId: string): MemoizedSelector { + const submissionIdSelector = submissionSectionFromIdSelector(submissionId, sectionId); + return subStateSelector(submissionIdSelector, 'errors'); +} diff --git a/src/app/submission/server-submission.service.ts b/src/app/submission/server-submission.service.ts new file mode 100644 index 0000000000..3aa55a9d58 --- /dev/null +++ b/src/app/submission/server-submission.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; + +import { SubmissionService } from './submission.service'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { RemoteData } from '../core/data/remote-data'; + +/** + * Instance of SubmissionService used on SSR. + */ +@Injectable() +export class ServerSubmissionService extends SubmissionService { + + /** + * Override createSubmission parent method to return an empty observable + * + * @return Observable + * observable of SubmissionObject + */ + createSubmission(): Observable { + return observableOf(null); + } + + /** + * Override retrieveSubmission parent method to return an empty observable + * + * @return Observable + * observable of SubmissionObject + */ + retrieveSubmission(submissionId): Observable> { + return observableOf(null); + } + + /** + * Override startAutoSave parent method and return without doing anything + * + * @param submissionId + * The submission id + */ + startAutoSave(submissionId) { + return; + } + + /** + * Override startAutoSave parent method and return without doing anything + */ + stopAutoSave() { + return; + } +} diff --git a/src/app/submission/submission.effects.ts b/src/app/submission/submission.effects.ts new file mode 100644 index 0000000000..30e01451d1 --- /dev/null +++ b/src/app/submission/submission.effects.ts @@ -0,0 +1,5 @@ +import { SubmissionObjectEffects } from './objects/submission-objects.effects'; + +export const submissionEffects = [ + SubmissionObjectEffects +]; diff --git a/src/app/submission/submission.module.ts b/src/app/submission/submission.module.ts new file mode 100644 index 0000000000..e6c24226e2 --- /dev/null +++ b/src/app/submission/submission.module.ts @@ -0,0 +1,79 @@ +import { NgModule } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../shared/shared.module'; + +import { SubmissionSectionformComponent } from './sections/form/section-form.component'; +import { SectionsDirective } from './sections/sections.directive'; +import { SectionsService } from './sections/sections.service'; +import { SubmissionFormCollectionComponent } from './form/collection/submission-form-collection.component'; +import { SubmissionFormFooterComponent } from './form/footer/submission-form-footer.component'; +import { SubmissionFormComponent } from './form/submission-form.component'; +import { SubmissionFormSectionAddComponent } from './form/section-add/submission-form-section-add.component'; +import { SubmissionSectionContainerComponent } from './sections/container/section-container.component'; +import { CommonModule } from '@angular/common'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { submissionReducers } from './submission.reducers'; +import { submissionEffects } from './submission.effects'; +import { SubmissionSectionUploadComponent } from './sections/upload/section-upload.component'; +import { SectionUploadService } from './sections/upload/section-upload.service'; +import { SubmissionUploadFilesComponent } from './form/submission-upload-files/submission-upload-files.component'; +import { SubmissionSectionLicenseComponent } from './sections/license/section-license.component'; +import { SubmissionUploadsConfigService } from '../core/config/submission-uploads-config.service'; +import { SubmissionEditComponent } from './edit/submission-edit.component'; +import { SubmissionSectionUploadFileComponent } from './sections/upload/file/section-upload-file.component'; +import { SubmissionSectionUploadFileEditComponent } from './sections/upload/file/edit/section-upload-file-edit.component'; +import { SubmissionSectionUploadFileViewComponent } from './sections/upload/file/view/section-upload-file-view.component'; +import { SubmissionSectionUploadAccessConditionsComponent } from './sections/upload/accessConditions/submission-section-upload-access-conditions.component'; +import { SubmissionSubmitComponent } from './submit/submission-submit.component'; + +@NgModule({ + imports: [ + CommonModule, + CoreModule, + SharedModule, + StoreModule.forFeature('submission', submissionReducers, {}), + EffectsModule.forFeature(submissionEffects), + TranslateModule + ], + declarations: [ + SubmissionSectionUploadAccessConditionsComponent, + SubmissionSectionUploadComponent, + SubmissionSectionformComponent, + SubmissionSectionLicenseComponent, + SectionsDirective, + SubmissionSectionContainerComponent, + SubmissionEditComponent, + SubmissionFormSectionAddComponent, + SubmissionFormCollectionComponent, + SubmissionFormComponent, + SubmissionFormFooterComponent, + SubmissionSubmitComponent, + SubmissionUploadFilesComponent, + SubmissionSectionUploadFileComponent, + SubmissionSectionUploadFileEditComponent, + SubmissionSectionUploadFileViewComponent + ], + entryComponents: [ + SubmissionSectionUploadComponent, + SubmissionSectionformComponent, + SubmissionSectionLicenseComponent, + SubmissionSectionContainerComponent], + exports: [ + SubmissionEditComponent, + SubmissionFormComponent, + SubmissionSubmitComponent + ], + providers: [ + SectionUploadService, + SectionsService, + SubmissionUploadsConfigService + ] +}) + +/** + * This module handles all components that are necessary for the submission process + */ +export class SubmissionModule { +} diff --git a/src/app/submission/submission.reducers.ts b/src/app/submission/submission.reducers.ts new file mode 100644 index 0000000000..939c4654ad --- /dev/null +++ b/src/app/submission/submission.reducers.ts @@ -0,0 +1,19 @@ +import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; + +import { + submissionObjectReducer, + SubmissionObjectState +} from './objects/submission-objects.reducer'; + +/** + * The Submission State + */ +export interface SubmissionState { + 'objects': SubmissionObjectState +} + +export const submissionReducers: ActionReducerMap = { + objects: submissionObjectReducer, +}; + +export const submissionSelector = createFeatureSelector('submission'); diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts new file mode 100644 index 0000000000..d764f09538 --- /dev/null +++ b/src/app/submission/submission.service.spec.ts @@ -0,0 +1,911 @@ +import { StoreModule } from '@ngrx/store'; +import { async, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpHeaders } from '@angular/common/http'; + +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { cold, getTestScheduler, hot, } from 'jasmine-marbles'; + +import { MockRouter } from '../shared/mocks/mock-router'; +import { SubmissionService } from './submission.service'; +import { submissionReducers } from './submission.reducers'; +import { SubmissionRestService } from '../core/submission/submission-rest.service'; +import { RouteService } from '../shared/services/route.service'; +import { SubmissionRestServiceStub } from '../shared/testing/submission-rest-service-stub'; +import { MockActivatedRoute } from '../shared/mocks/mock-active-router'; +import { GLOBAL_CONFIG } from '../../config'; +import { HttpOptions } from '../core/dspace-rest-v2/dspace-rest-v2.service'; +import { SubmissionScopeType } from '../core/submission/submission-scope-type'; +import { mockSubmissionDefinition, mockSubmissionRestResponse } from '../shared/mocks/mock-submission'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { MockTranslateLoader } from '../shared/mocks/mock-translate-loader'; +import { MOCK_SUBMISSION_CONFIG } from '../shared/testing/mock-submission-config'; +import { + CancelSubmissionFormAction, + ChangeSubmissionCollectionAction, + DiscardSubmissionAction, + InitSubmissionFormAction, + ResetSubmissionFormAction, + SaveAndDepositSubmissionAction, + SaveForLaterSubmissionFormAction, + SaveSubmissionFormAction, + SaveSubmissionSectionFormAction, + SetActiveSectionAction +} from './objects/submission-objects.actions'; +import { RemoteData } from '../core/data/remote-data'; +import { RemoteDataError } from '../core/data/remote-data-error'; +import { throwError as observableThrowError } from 'rxjs/internal/observable/throwError'; + +describe('SubmissionService test suite', () => { + const config = MOCK_SUBMISSION_CONFIG; + const collectionId = '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f'; + const submissionId = '826'; + const sectionId = 'test'; + const subState = { + objects: { + 826: { + collection: '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f', + definition: 'traditional', + selfUrl: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', + activeSection: 'keyinformation', + sections: { + extraction: { + config: '', + mandatory: true, + sectionType: 'utils', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + collection: { + config: '', + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + keyinformation: { + header: 'submit.progressbar.describe.keyinformation', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/keyinformation', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + indexing: { + header: 'submit.progressbar.describe.indexing', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/indexing', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + publicationchannel: { + header: 'submit.progressbar.describe.publicationchannel', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/publicationchannel', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: true + }, + acknowledgement: { + header: 'submit.progressbar.describe.acknowledgement', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/acknowledgement', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + identifiers: { + header: 'submit.progressbar.describe.identifiers', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/identifiers', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + references: { + header: 'submit.progressbar.describe.references', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/references', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + upload: { + header: 'submit.progressbar.upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload', + mandatory: true, + sectionType: 'upload', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + license: { + header: 'submit.progressbar.license', + config: '', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + } + }, + isLoading: false, + savePending: false, + depositPending: false + } + } + }; + const validSubState = { + objects: { + 826: { + collection: '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f', + definition: 'traditional', + selfUrl: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', + activeSection: 'keyinformation', + sections: { + extraction: { + config: '', + mandatory: true, + sectionType: 'utils', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + collection: { + config: '', + mandatory: true, + sectionType: 'collection', + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + keyinformation: { + header: 'submit.progressbar.describe.keyinformation', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/keyinformation', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: true + }, + indexing: { + header: 'submit.progressbar.describe.indexing', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/indexing', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + publicationchannel: { + header: 'submit.progressbar.describe.publicationchannel', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/publicationchannel', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: true + }, + acknowledgement: { + header: 'submit.progressbar.describe.acknowledgement', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/acknowledgement', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + identifiers: { + header: 'submit.progressbar.describe.identifiers', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/identifiers', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + references: { + header: 'submit.progressbar.describe.references', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/references', + mandatory: false, + sectionType: 'submission-form', + collapsed: false, + enabled: false, + data: {}, + errors: [], + isLoading: false, + isValid: false + }, + upload: { + header: 'submit.progressbar.upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload', + mandatory: true, + sectionType: 'upload', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: true + }, + license: { + header: 'submit.progressbar.license', + config: '', + mandatory: true, + sectionType: 'license', + visibility: { + main: null, + other: 'READONLY' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: true + } + }, + isLoading: false, + savePending: false, + depositPending: false + } + } + }; + const restService = new SubmissionRestServiceStub(); + const router = new MockRouter(); + const selfUrl = 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826'; + const submissionDefinition: any = mockSubmissionDefinition; + + let scheduler: TestScheduler; + let service: SubmissionService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({submissionReducers} as any), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + providers: [ + {provide: GLOBAL_CONFIG, useValue: config}, + {provide: Router, useValue: router}, + {provide: SubmissionRestService, useValue: restService}, + {provide: ActivatedRoute, useValue: new MockActivatedRoute()}, + NotificationsService, + RouteService, + SubmissionService, + TranslateService + ] + }).compileComponents(); + })); + + beforeEach(() => { + service = TestBed.get(SubmissionService); + spyOn((service as any).store, 'dispatch').and.callThrough(); + }); + + describe('changeSubmissionCollection', () => { + it('should dispatch a new ChangeSubmissionCollectionAction', () => { + service.changeSubmissionCollection(submissionId, collectionId); + const expected = new ChangeSubmissionCollectionAction(submissionId, collectionId); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('createSubmission', () => { + it('should create a new submission', () => { + service.createSubmission(); + + expect((service as any).restService.postToEndpoint).toHaveBeenCalled(); + }); + }); + + describe('depositSubmission', () => { + it('should deposit submission', () => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + service.depositSubmission(selfUrl); + + expect((service as any).restService.postToEndpoint).toHaveBeenCalledWith('workflowitems', selfUrl, null, options); + }); + }); + + describe('discardSubmission', () => { + it('should discard submission', () => { + service.discardSubmission('826'); + + expect((service as any).restService.deleteById).toHaveBeenCalledWith('826'); + }); + }); + + describe('dispatchInit', () => { + it('should dispatch a new InitSubmissionFormAction', () => { + service.dispatchInit( + collectionId, + submissionId, + selfUrl, + submissionDefinition, + {}, + [] + ); + const expected = new InitSubmissionFormAction( + collectionId, + submissionId, + selfUrl, + submissionDefinition, + {}, + []); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('dispatchDeposit', () => { + it('should dispatch a new SaveAndDepositSubmissionAction', () => { + service.dispatchDeposit(submissionId,); + const expected = new SaveAndDepositSubmissionAction(submissionId); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('dispatchDiscard', () => { + it('should dispatch a new DiscardSubmissionAction', () => { + service.dispatchDiscard(submissionId,); + const expected = new DiscardSubmissionAction(submissionId); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('dispatchSave', () => { + it('should dispatch a new SaveSubmissionFormAction', () => { + service.dispatchSave(submissionId,); + const expected = new SaveSubmissionFormAction(submissionId); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('dispatchSaveForLater', () => { + it('should dispatch a new SaveForLaterSubmissionFormAction', () => { + service.dispatchSaveForLater(submissionId,); + const expected = new SaveForLaterSubmissionFormAction(submissionId); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('dispatchSaveSection', () => { + it('should dispatch a new SaveSubmissionSectionFormAction', () => { + service.dispatchSaveSection(submissionId, sectionId); + const expected = new SaveSubmissionSectionFormAction(submissionId, sectionId); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('getSubmissionObject', () => { + it('should return submission object state from the store', () => { + spyOn((service as any).store, 'select').and.returnValue(hot('a', { + a: subState.objects[826] + })); + + const result = service.getSubmissionObject('826'); + const expected = cold('b', {b: subState.objects[826]}); + + expect(result).toBeObservable(expected); + }); + }); + + describe('getActiveSectionId', () => { + it('should return current active submission form section', () => { + spyOn((service as any).store, 'select').and.returnValue(hot('a', { + a: subState.objects[826] + })); + + const result = service.getActiveSectionId('826'); + const expected = cold('b', {b: 'keyinformation'}); + + expect(result).toBeObservable(expected); + + }); + }); + + describe('getSubmissionSections', () => { + it('should return submission form sections', () => { + spyOn((service as any).store, 'select').and.returnValue(hot('a|', { + a: subState.objects[826] + })); + + const result = service.getSubmissionSections('826'); + const expected = cold('(bc|)', { + b: [], + c: + [ + { + header: 'submit.progressbar.describe.keyinformation', + id: 'keyinformation', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/keyinformation', + mandatory: true, + sectionType: 'submission-form', + data: {}, + errors: [] + }, + { + header: 'submit.progressbar.describe.indexing', + id: 'indexing', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/indexing', + mandatory: false, + sectionType: 'submission-form', + data: {}, + errors: [] + }, + { + header: 'submit.progressbar.describe.publicationchannel', + id: 'publicationchannel', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/publicationchannel', + mandatory: true, + sectionType: 'submission-form', + data: {}, + errors: [] + }, + { + header: 'submit.progressbar.describe.acknowledgement', + id: 'acknowledgement', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/acknowledgement', + mandatory: false, + sectionType: 'submission-form', + data: {}, + errors: [] + }, + { + header: 'submit.progressbar.describe.identifiers', + id: 'identifiers', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/identifiers', + mandatory: false, + sectionType: 'submission-form', + data: {}, + errors: [] + }, + { + header: 'submit.progressbar.describe.references', + id: 'references', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/references', + mandatory: false, + sectionType: 'submission-form', + data: {}, + errors: [] + }, + { + header: 'submit.progressbar.upload', + id: 'upload', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload', + mandatory: true, + sectionType: 'upload', + data: {}, + errors: [] + }, + { + header: 'submit.progressbar.license', + id: 'license', + config: '', + mandatory: true, + sectionType: 'license', + data: {}, + errors: [] + } + ] + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('getDisabledSectionsList', () => { + it('should return list of submission disabled sections', () => { + spyOn((service as any).store, 'select').and.returnValue(hot('-a|', { + a: subState.objects[826] + })); + + const result = service.getDisabledSectionsList('826'); + const expected = cold('bc|', { + b: [], + c: + [ + { + header: 'submit.progressbar.describe.indexing', + id: 'indexing', + }, + { + header: 'submit.progressbar.describe.acknowledgement', + id: 'acknowledgement', + }, + { + header: 'submit.progressbar.describe.identifiers', + id: 'identifiers', + }, + { + header: 'submit.progressbar.describe.references', + id: 'references', + } + ] + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('getSubmissionObjectLinkName', () => { + it('should return properly submission link name', () => { + let expected = 'workspaceitems'; + router.setRoute('/workspaceitems/826/edit'); + expect(service.getSubmissionObjectLinkName()).toBe(expected); + + expected = 'workspaceitems'; + router.setRoute('/submit'); + expect(service.getSubmissionObjectLinkName()).toBe(expected); + + expected = 'workflowitems'; + router.setRoute('/workflowitems/826/edit'); + expect(service.getSubmissionObjectLinkName()).toBe(expected); + + expected = 'edititems'; + router.setRoute('/items/9e79b1f2-ae0f-4737-9a4b-990952a8857c/edit'); + expect(service.getSubmissionObjectLinkName()).toBe(expected); + }); + }); + + describe('getSubmissionScope', () => { + it('should return properly submission scope', () => { + let expected = SubmissionScopeType.WorkspaceItem; + + router.setRoute('/workspaceitems/826/edit'); + expect(service.getSubmissionScope()).toBe(expected); + + router.setRoute('/submit'); + expect(service.getSubmissionScope()).toBe(expected); + + expected = SubmissionScopeType.WorkflowItem; + router.setRoute('/workflowitems/826/edit'); + expect(service.getSubmissionScope()).toBe(expected); + + }); + }); + + describe('getSubmissionStatus', () => { + it('should return properly submission status', () => { + spyOn((service as any).store, 'select').and.returnValue(hot('-a-b', { + a: subState, + b: validSubState + })); + const result = service.getSubmissionStatus('826'); + const expected = cold('cc-d', { + c: false, + d: true + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('getSubmissionSaveProcessingStatus', () => { + it('should return submission save processing status', () => { + spyOn((service as any).store, 'select').and.returnValue(hot('-a', { + a: subState.objects[826] + })); + + const result = service.getSubmissionSaveProcessingStatus('826'); + const expected = cold('bb', { + b: false + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('getSubmissionDepositProcessingStatus', () => { + it('should return submission deposit processing status', () => { + spyOn((service as any).store, 'select').and.returnValue(hot('-a', { + a: subState.objects[826] + })); + + const result = service.getSubmissionDepositProcessingStatus('826'); + const expected = cold('bb', { + b: false + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('isSectionHidden', () => { + it('should return true/false when section is hidden/visible', () => { + let section: any = { + config: '', + header: '', + mandatory: true, + sectionType: 'collection' as any, + visibility: { + main: 'HIDDEN', + other: 'HIDDEN' + }, + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }; + expect(service.isSectionHidden(section)).toBeTruthy(); + + section = { + header: 'submit.progressbar.describe.keyinformation', + config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/keyinformation', + mandatory: true, + sectionType: 'submission-form', + collapsed: false, + enabled: true, + data: {}, + errors: [], + isLoading: false, + isValid: false + }; + expect(service.isSectionHidden(section)).toBeFalsy(); + }); + }); + + describe('isSubmissionLoading', () => { + it('should return true/false when section is loading/not loading', () => { + const spy = spyOn(service, 'getSubmissionObject').and.returnValue(observableOf({isLoading: true})); + + let expected = cold('(b|)', { + b: true + }); + + expect(service.isSubmissionLoading(submissionId)).toBeObservable(expected); + + spy.and.returnValue(observableOf({isLoading: false})); + + expected = cold('(b|)', { + b: false + }); + + expect(service.isSubmissionLoading(submissionId)).toBeObservable(expected); + }); + }); + + describe('notifyNewSection', () => { + it('should return true/false when section is loading/not loading', fakeAsync(() => { + spyOn((service as any).translate, 'get').and.returnValue(observableOf('test')); + + spyOn((service as any).notificationsService, 'info'); + + service.notifyNewSection(submissionId, sectionId); + flush(); + + expect((service as any).notificationsService.info).toHaveBeenCalledWith(null, 'submission.sections.general.metadata-extracted-new-section', null, true); + })); + }); + + describe('redirectToMyDSpace', () => { + it('should redirect to MyDspace page', () => { + scheduler = getTestScheduler(); + const spy = spyOn((service as any).routeService, 'getPreviousUrl'); + + spy.and.returnValue(observableOf('/mydspace?configuration=workflow')); + scheduler.schedule(() => service.redirectToMyDSpace()); + scheduler.flush(); + + expect((service as any).router.navigateByUrl).toHaveBeenCalledWith('/mydspace?configuration=workflow'); + + spy.and.returnValue(observableOf('')); + scheduler.schedule(() => service.redirectToMyDSpace()); + scheduler.flush(); + + expect((service as any).router.navigate).toHaveBeenCalledWith(['/mydspace']); + + spy.and.returnValue(observableOf('/home')); + scheduler.schedule(() => service.redirectToMyDSpace()); + scheduler.flush(); + + expect((service as any).router.navigate).toHaveBeenCalledWith(['/mydspace']); + }); + }); + + describe('resetAllSubmissionObjects', () => { + it('should dispatch a new CancelSubmissionFormAction', () => { + service.resetAllSubmissionObjects(); + const expected = new CancelSubmissionFormAction(); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('resetSubmissionObject', () => { + it('should dispatch a new ResetSubmissionFormAction', () => { + service.resetSubmissionObject( + collectionId, + submissionId, + selfUrl, + submissionDefinition, + {} + ); + const expected = new ResetSubmissionFormAction( + collectionId, + submissionId, + selfUrl, + {}, + submissionDefinition + ); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('retrieveSubmission', () => { + it('should retrieve submission from REST endpoint', () => { + (service as any).restService.getDataById.and.returnValue(hot('a|', { + a: mockSubmissionRestResponse + })); + + const result = service.retrieveSubmission('826'); + const expected = cold('(b|)', { + b: new RemoteData( + false, + false, + true, + null, + mockSubmissionRestResponse[0]) + }); + + expect(result).toBeObservable(expected); + }); + + it('should catch error from REST endpoint', () => { + (service as any).restService.getDataById.and.callFake( + () => observableThrowError({ + statusCode: 500, + statusText: 'Internal Server Error', + errorMessage: 'Error message' + }) + ); + + service.retrieveSubmission('826').subscribe((r) => { + expect(r).toEqual(new RemoteData( + false, + false, + false, + new RemoteDataError(500, 'Internal Server Error', 'Error message'), + null + )) + }); + }); + }); + + describe('setActiveSection', () => { + it('should dispatch a new SetActiveSectionAction', () => { + service.setActiveSection(submissionId, sectionId); + const expected = new SetActiveSectionAction(submissionId, sectionId); + + expect((service as any).store.dispatch).toHaveBeenCalledWith(expected); + }); + }); + + describe('startAutoSave', () => { + it('should start Auto Save', fakeAsync(() => { + const duration = config.submission.autosave.timer * (1000 * 60); + + service.startAutoSave('826'); + const sub = (service as any).timer$.subscribe(); + + tick(duration / 2); + expect((service as any).store.dispatch).not.toHaveBeenCalled(); + + tick(duration / 2); + expect((service as any).store.dispatch).toHaveBeenCalled(); + + sub.unsubscribe(); + (service as any).autoSaveSub.unsubscribe(); + })); + }); + + describe('stopAutoSave', () => { + it('should stop Auto Save', () => { + service.startAutoSave('826'); + service.stopAutoSave(); + + expect((service as any).autoSaveSub).toBeNull(); + }); + }); +}); diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts new file mode 100644 index 0000000000..82185a8eae --- /dev/null +++ b/src/app/submission/submission.service.ts @@ -0,0 +1,554 @@ +import { Inject, Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; +import { Router } from '@angular/router'; + +import { Observable, of as observableOf, Subscription, timer as observableTimer } from 'rxjs'; +import { catchError, distinctUntilChanged, filter, find, first, map, startWith } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; + +import { submissionSelector, SubmissionState } from './submission.reducers'; +import { hasValue, isEmpty, isNotUndefined } from '../shared/empty.util'; +import { + CancelSubmissionFormAction, + ChangeSubmissionCollectionAction, + DiscardSubmissionAction, + InitSubmissionFormAction, + ResetSubmissionFormAction, + SaveAndDepositSubmissionAction, + SaveForLaterSubmissionFormAction, + SaveSubmissionFormAction, + SaveSubmissionSectionFormAction, + SetActiveSectionAction +} from './objects/submission-objects.actions'; +import { + SubmissionObjectEntry, + SubmissionSectionEntry, + SubmissionSectionError, + SubmissionSectionObject +} from './objects/submission-objects.reducer'; +import { submissionObjectFromIdSelector } from './selectors'; +import { GlobalConfig } from '../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../config'; +import { HttpOptions } from '../core/dspace-rest-v2/dspace-rest-v2.service'; +import { SubmissionRestService } from '../core/submission/submission-rest.service'; +import { SectionDataObject } from './sections/models/section-data.model'; +import { SubmissionScopeType } from '../core/submission/submission-scope-type'; +import { SubmissionObject } from '../core/submission/models/submission-object.model'; +import { RouteService } from '../shared/services/route.service'; +import { SectionsType } from './sections/sections-type'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { SubmissionDefinitionsModel } from '../core/config/models/config-submission-definitions.model'; +import { WorkspaceitemSectionsObject } from '../core/submission/models/workspaceitem-sections.model'; +import { RemoteData } from '../core/data/remote-data'; +import { ErrorResponse } from '../core/cache/response.models'; +import { RemoteDataError } from '../core/data/remote-data-error'; + +/** + * A service that provides methods used in submission process. + */ +@Injectable() +export class SubmissionService { + + /** + * Subscription + */ + protected autoSaveSub: Subscription; + + /** + * Observable used as timer + */ + protected timer$: Observable; + + /** + * Initialize service variables + * @param {GlobalConfig} EnvConfig + * @param {NotificationsService} notificationsService + * @param {SubmissionRestService} restService + * @param {Router} router + * @param {RouteService} routeService + * @param {Store} store + * @param {TranslateService} translate + */ + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected notificationsService: NotificationsService, + protected restService: SubmissionRestService, + protected router: Router, + protected routeService: RouteService, + protected store: Store, + protected translate: TranslateService) { + } + + /** + * Dispatch a new [ChangeSubmissionCollectionAction] + * + * @param submissionId + * The submission id + * @param collectionId + * The collection id + */ + changeSubmissionCollection(submissionId, collectionId) { + this.store.dispatch(new ChangeSubmissionCollectionAction(submissionId, collectionId)); + } + + /** + * Perform a REST call to create a new workspaceitem and return response + * + * @return Observable + * observable of SubmissionObject + */ + createSubmission(): Observable { + return this.restService.postToEndpoint('workspaceitems', {}).pipe( + map((workspaceitem: SubmissionObject) => workspaceitem[0]), + catchError(() => observableOf({}))) + } + + /** + * Perform a REST call to deposit a workspaceitem and return response + * + * @param selfUrl + * The workspaceitem self url + * @return Observable + * observable of SubmissionObject + */ + depositSubmission(selfUrl: string): Observable { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return this.restService.postToEndpoint('workflowitems', selfUrl, null, options) as Observable; + } + + /** + * Perform a REST call to delete a workspaceitem and return response + * + * @param submissionId + * The submission id + * @return Observable + * observable of SubmissionObject + */ + discardSubmission(submissionId: string): Observable { + return this.restService.deleteById(submissionId) as Observable; + } + + /** + * Dispatch a new [InitSubmissionFormAction] + * + * @param collectionId + * The collection id + * @param submissionId + * The submission id + * @param selfUrl + * The workspaceitem self url + * @param submissionDefinition + * The [SubmissionDefinitionsModel] that define submission configuration + * @param sections + * The [WorkspaceitemSectionsObject] that define submission sections init data + * @param errors + * The [SubmissionSectionError] that define submission sections init errors + */ + dispatchInit( + collectionId: string, + submissionId: string, + selfUrl: string, + submissionDefinition: SubmissionDefinitionsModel, + sections: WorkspaceitemSectionsObject, + errors: SubmissionSectionError[]) { + this.store.dispatch(new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, sections, errors)); + } + + /** + * Dispatch a new [SaveAndDepositSubmissionAction] + * + * @param submissionId + * The submission id + */ + dispatchDeposit(submissionId) { + this.store.dispatch(new SaveAndDepositSubmissionAction(submissionId)); + } + + /** + * Dispatch a new [DiscardSubmissionAction] + * + * @param submissionId + * The submission id + */ + dispatchDiscard(submissionId) { + this.store.dispatch(new DiscardSubmissionAction(submissionId)); + } + + /** + * Dispatch a new [SaveSubmissionFormAction] + * + * @param submissionId + * The submission id + */ + dispatchSave(submissionId) { + this.store.dispatch(new SaveSubmissionFormAction(submissionId)); + } + + /** + * Dispatch a new [SaveForLaterSubmissionFormAction] + * + * @param submissionId + * The submission id + */ + dispatchSaveForLater(submissionId) { + this.store.dispatch(new SaveForLaterSubmissionFormAction(submissionId)); + } + + /** + * Dispatch a new [SaveSubmissionSectionFormAction] + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + */ + dispatchSaveSection(submissionId, sectionId) { + this.store.dispatch(new SaveSubmissionSectionFormAction(submissionId, sectionId)); + } + + /** + * Return the id of the current focused section for the specified submission + * + * @param submissionId + * The submission id + * @return Observable + * observable of section id + */ + getActiveSectionId(submissionId: string): Observable { + return this.getSubmissionObject(submissionId).pipe( + map((submission: SubmissionObjectEntry) => submission.activeSection)); + } + + /** + * Return the [SubmissionObjectEntry] for the specified submission + * + * @param submissionId + * The submission id + * @return Observable + * observable of SubmissionObjectEntry + */ + getSubmissionObject(submissionId: string): Observable { + return this.store.select(submissionObjectFromIdSelector(submissionId)).pipe( + filter((submission: SubmissionObjectEntry) => isNotUndefined(submission))); + } + + /** + * Return a list of the active [SectionDataObject] belonging to the specified submission + * + * @param submissionId + * The submission id + * @return Observable + * observable with the list of active submission's sections + */ + getSubmissionSections(submissionId: string): Observable { + return this.getSubmissionObject(submissionId).pipe( + find((submission: SubmissionObjectEntry) => isNotUndefined(submission.sections) && !submission.isLoading), + map((submission: SubmissionObjectEntry) => submission.sections), + map((sections: SubmissionSectionEntry) => { + const availableSections: SectionDataObject[] = []; + Object.keys(sections) + .filter((sectionId) => !this.isSectionHidden(sections[sectionId] as SubmissionSectionObject)) + .forEach((sectionId) => { + const sectionObject: SectionDataObject = Object.create({}); + sectionObject.config = sections[sectionId].config; + sectionObject.mandatory = sections[sectionId].mandatory; + sectionObject.data = sections[sectionId].data; + sectionObject.errors = sections[sectionId].errors; + sectionObject.header = sections[sectionId].header; + sectionObject.id = sectionId; + sectionObject.sectionType = sections[sectionId].sectionType; + availableSections.push(sectionObject); + }); + return availableSections; + }), + startWith([]), + distinctUntilChanged()); + } + + /** + * Return a list of the disabled [SectionDataObject] belonging to the specified submission + * + * @param submissionId + * The submission id + * @return Observable + * observable with the list of disabled submission's sections + */ + getDisabledSectionsList(submissionId: string): Observable { + return this.getSubmissionObject(submissionId).pipe( + filter((submission: SubmissionObjectEntry) => isNotUndefined(submission.sections) && !submission.isLoading), + map((submission: SubmissionObjectEntry) => submission.sections), + map((sections: SubmissionSectionEntry) => { + const disabledSections: SectionDataObject[] = []; + Object.keys(sections) + .filter((sectionId) => !this.isSectionHidden(sections[sectionId] as SubmissionSectionObject)) + .filter((sectionId) => !sections[sectionId].enabled) + .forEach((sectionId) => { + const sectionObject: SectionDataObject = Object.create({}); + sectionObject.header = sections[sectionId].header; + sectionObject.id = sectionId; + disabledSections.push(sectionObject); + }); + return disabledSections; + }), + startWith([]), + distinctUntilChanged()); + } + + /** + * Return the correct REST endpoint link path depending on the page route + * + * @return string + * link path + */ + getSubmissionObjectLinkName(): string { + const url = this.router.routerState.snapshot.url; + if (url.startsWith('/workspaceitems') || url.startsWith('/submit')) { + return 'workspaceitems'; + } else if (url.startsWith('/workflowitems')) { + return 'workflowitems'; + } else { + return 'edititems'; + } + } + + /** + * Return the submission scope + * + * @return SubmissionScopeType + * the SubmissionScopeType + */ + getSubmissionScope(): SubmissionScopeType { + let scope: SubmissionScopeType; + switch (this.getSubmissionObjectLinkName()) { + case 'workspaceitems': + scope = SubmissionScopeType.WorkspaceItem; + break; + case 'workflowitems': + scope = SubmissionScopeType.WorkflowItem; + break; + } + return scope; + } + + /** + * Return the validity status of the submission + * + * @param submissionId + * The submission id + * @return Observable + * observable with submission validity status + */ + getSubmissionStatus(submissionId: string): Observable { + return this.store.select(submissionSelector).pipe( + map((submissions: SubmissionState) => submissions.objects[submissionId]), + filter((item) => isNotUndefined(item) && isNotUndefined(item.sections)), + map((item) => item.sections), + map((sections) => { + const states = []; + + if (isNotUndefined(sections)) { + Object.keys(sections) + .filter((sectionId) => sections.hasOwnProperty(sectionId)) + .filter((sectionId) => !this.isSectionHidden(sections[sectionId] as SubmissionSectionObject)) + .filter((sectionId) => sections[sectionId].enabled) + .filter((sectionId) => sections[sectionId].isValid === false) + .forEach((sectionId) => { + states.push(sections[sectionId].isValid); + }); + } + + return !isEmpty(sections) && isEmpty(states); + }), + distinctUntilChanged(), + startWith(false)); + } + + /** + * Return the save processing status of the submission + * + * @param submissionId + * The submission id + * @return Observable + * observable with submission save processing status + */ + getSubmissionSaveProcessingStatus(submissionId: string): Observable { + return this.getSubmissionObject(submissionId).pipe( + map((state: SubmissionObjectEntry) => state.savePending), + distinctUntilChanged(), + startWith(false)); + } + + /** + * Return the deposit processing status of the submission + * + * @param submissionId + * The submission id + * @return Observable + * observable with submission deposit processing status + */ + getSubmissionDepositProcessingStatus(submissionId: string): Observable { + return this.getSubmissionObject(submissionId).pipe( + map((state: SubmissionObjectEntry) => state.depositPending), + distinctUntilChanged(), + startWith(false)); + } + + /** + * Return the visibility status of the specified section + * + * @param sectionData + * The section data + * @return boolean + * true if section is hidden, false otherwise + */ + isSectionHidden(sectionData: SubmissionSectionObject): boolean { + return (isNotUndefined(sectionData.visibility) + && sectionData.visibility.main === 'HIDDEN' + && sectionData.visibility.other === 'HIDDEN'); + } + + /** + * Return the loading status of the submission + * + * @param submissionId + * The submission id + * @return Observable + * observable with submission loading status + */ + isSubmissionLoading(submissionId: string): Observable { + return this.getSubmissionObject(submissionId).pipe( + map((submission: SubmissionObjectEntry) => submission.isLoading), + distinctUntilChanged()); + } + + /** + * Show a notification when a new section is added to submission form + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + * @param sectionType + * The section type + */ + notifyNewSection(submissionId: string, sectionId: string, sectionType?: SectionsType) { + const m = this.translate.instant('submission.sections.general.metadata-extracted-new-section', { sectionId }); + this.notificationsService.info(null, m, null, true); + } + + /** + * Redirect to MyDspace page + */ + redirectToMyDSpace() { + this.routeService.getPreviousUrl().pipe( + first() + ).subscribe((previousUrl: string) => { + if (isEmpty(previousUrl) || !previousUrl.startsWith('/mydspace')) { + this.router.navigate(['/mydspace']); + } else { + this.router.navigateByUrl(previousUrl); + } + }); + + } + + /** + * Dispatch a new [CancelSubmissionFormAction] + */ + resetAllSubmissionObjects() { + this.store.dispatch(new CancelSubmissionFormAction()); + } + + /** + * Dispatch a new [ResetSubmissionFormAction] + * + * @param collectionId + * The collection id + * @param submissionId + * The submission id + * @param selfUrl + * The workspaceitem self url + * @param submissionDefinition + * The [SubmissionDefinitionsModel] that define submission configuration + * @param sections + * The [WorkspaceitemSectionsObject] that define submission sections init data + */ + resetSubmissionObject( + collectionId: string, + submissionId: string, + selfUrl: string, + submissionDefinition: SubmissionDefinitionsModel, + sections: WorkspaceitemSectionsObject + ) { + this.store.dispatch(new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, sections, submissionDefinition)); + } + + /** + * Perform a REST call to retrieve an existing workspaceitem/workflowitem and return response + * + * @return Observable> + * observable of RemoteData + */ + retrieveSubmission(submissionId): Observable> { + return this.restService.getDataById(this.getSubmissionObjectLinkName(), submissionId).pipe( + find((submissionObjects: SubmissionObject[]) => isNotUndefined(submissionObjects)), + map((submissionObjects: SubmissionObject[]) => new RemoteData( + false, + false, + true, + null, + submissionObjects[0])), + catchError((errorResponse: ErrorResponse) => { + return observableOf(new RemoteData( + false, + false, + false, + new RemoteDataError(errorResponse.statusCode, errorResponse.statusText, errorResponse.errorMessage), + null + )) + }) + ); + } + + /** + * Dispatch a new [SetActiveSectionAction] + * + * @param submissionId + * The submission id + * @param sectionId + * The section id + */ + setActiveSection(submissionId, sectionId) { + this.store.dispatch(new SetActiveSectionAction(submissionId, sectionId)); + } + + /** + * Allow to save automatically the submission + * + * @param submissionId + * The submission id + */ + startAutoSave(submissionId) { + this.stopAutoSave(); + // AUTOSAVE submission + // Retrieve interval from config and convert to milliseconds + const duration = this.EnvConfig.submission.autosave.timer * (1000 * 60); + // Dispatch save action after given duration + this.timer$ = observableTimer(duration, duration); + this.autoSaveSub = this.timer$ + .subscribe(() => this.store.dispatch(new SaveSubmissionFormAction(submissionId))); + } + + /** + * Unsubscribe subscription to timer + */ + stopAutoSave() { + if (hasValue(this.autoSaveSub)) { + this.autoSaveSub.unsubscribe(); + this.autoSaveSub = null; + } + } +} diff --git a/src/app/submission/submit/submission-submit.component.html b/src/app/submission/submit/submission-submit.component.html new file mode 100644 index 0000000000..c9e8c6b51a --- /dev/null +++ b/src/app/submission/submit/submission-submit.component.html @@ -0,0 +1,8 @@ +
    +
    + +
    +
    diff --git a/src/app/submission/submit/submission-submit.component.scss b/src/app/submission/submit/submission-submit.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/submit/submission-submit.component.spec.ts b/src/app/submission/submit/submission-submit.component.spec.ts new file mode 100644 index 0000000000..771171a2d1 --- /dev/null +++ b/src/app/submission/submit/submission-submit.component.spec.ts @@ -0,0 +1,96 @@ +import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Router } from '@angular/router'; +import { NO_ERRORS_SCHEMA, ViewContainerRef } from '@angular/core'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { SubmissionService } from '../submission.service'; +import { SubmissionServiceStub } from '../../shared/testing/submission-service-stub'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; +import { RouterStub } from '../../shared/testing/router-stub'; +import { mockSubmissionObject } from '../../shared/mocks/mock-submission'; +import { SubmissionSubmitComponent } from './submission-submit.component'; + +describe('SubmissionSubmitComponent Component', () => { + + let comp: SubmissionSubmitComponent; + let fixture: ComponentFixture; + let submissionServiceStub: SubmissionServiceStub; + let router: RouterStub; + + const submissionId = '826'; + const submissionObject: any = mockSubmissionObject; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([ + { path: '', component: SubmissionSubmitComponent, pathMatch: 'full' }, + ]) + ], + declarations: [SubmissionSubmitComponent], + providers: [ + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: Router, useValue: new RouterStub() }, + ViewContainerRef + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSubmitComponent); + comp = fixture.componentInstance; + submissionServiceStub = TestBed.get(SubmissionService); + router = TestBed.get(Router); + }); + + afterEach(() => { + comp = null; + fixture = null; + router = null; + }); + + it('should init properly when a valid SubmissionObject has been retrieved', fakeAsync(() => { + + submissionServiceStub.createSubmission.and.returnValue(observableOf(submissionObject)); + + fixture.detectChanges(); + + expect(comp.submissionId.toString()).toEqual(submissionId); + expect(comp.collectionId).toBe(submissionObject.collection.id); + expect(comp.selfUrl).toBe(submissionObject.self); + expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); + + })); + + it('should redirect to mydspace when an empty SubmissionObject has been retrieved', fakeAsync(() => { + + submissionServiceStub.createSubmission.and.returnValue(observableOf({})); + + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalled(); + + })); + + it('should not has effects when an invalid SubmissionObject has been retrieved', fakeAsync(() => { + + submissionServiceStub.createSubmission.and.returnValue(observableOf(null)); + + fixture.detectChanges(); + + expect(router.navigate).not.toHaveBeenCalled(); + expect(comp.collectionId).toBeUndefined(); + expect(comp.selfUrl).toBeUndefined(); + expect(comp.submissionDefinition).toBeUndefined(); + })); + +}); diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts new file mode 100644 index 0000000000..dbfd2f5a40 --- /dev/null +++ b/src/app/submission/submit/submission-submit.component.ts @@ -0,0 +1,109 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewContainerRef } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Subscription } from 'rxjs'; + +import { hasValue, isEmpty, isNotNull } from '../../shared/empty.util'; +import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SubmissionService } from '../submission.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { Collection } from '../../core/shared/collection.model'; + +/** + * This component allows to submit a new workspaceitem. + */ +@Component({ + selector: 'ds-submission-submit', + styleUrls: ['./submission-submit.component.scss'], + templateUrl: './submission-submit.component.html' +}) +export class SubmissionSubmitComponent implements OnDestroy, OnInit { + + /** + * The collection id this submission belonging to + * @type {string} + */ + public collectionId: string; + + /** + * The submission self url + * @type {string} + */ + public selfUrl: string; + + /** + * The configuration object that define this submission + * @type {SubmissionDefinitionsModel} + */ + public submissionDefinition: SubmissionDefinitionsModel; + + /** + * The submission id + * @type {string} + */ + public submissionId: string; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} changeDetectorRef + * @param {NotificationsService} notificationsService + * @param {SubmissionService} submissioService + * @param {Router} router + * @param {TranslateService} translate + * @param {ViewContainerRef} viewContainerRef + */ + constructor(private changeDetectorRef: ChangeDetectorRef, + private notificationsService: NotificationsService, + private router: Router, + private submissioService: SubmissionService, + private translate: TranslateService, + private viewContainerRef: ViewContainerRef) { + } + + /** + * Create workspaceitem on the server and initialize all instance variables + */ + ngOnInit() { + // NOTE execute the code on the browser side only, otherwise it is executed twice + this.subs.push( + this.submissioService.createSubmission() + .subscribe((submissionObject: SubmissionObject) => { + // NOTE new submission is created on the browser side only + if (isNotNull(submissionObject)) { + if (isEmpty(submissionObject)) { + this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); + this.router.navigate(['/mydspace']); + } else { + this.collectionId = (submissionObject.collection as Collection).id; + this.selfUrl = submissionObject.self; + this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); + this.submissionId = submissionObject.id; + this.changeDetectorRef.detectChanges(); + } + } + }) + ) + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + + this.viewContainerRef.clear(); + this.changeDetectorRef.markForCheck(); + } + +} diff --git a/src/app/submission/utils/parseSectionErrorPaths.ts b/src/app/submission/utils/parseSectionErrorPaths.ts new file mode 100644 index 0000000000..4c973dedcf --- /dev/null +++ b/src/app/submission/utils/parseSectionErrorPaths.ts @@ -0,0 +1,60 @@ +import { hasValue } from '../../shared/empty.util'; + +/** + * An interface to represent the path of a section error + */ +export interface SectionErrorPath { + + /** + * The section id + */ + sectionId: string; + + /** + * The form field id + */ + fieldId?: string; + + /** + * The form field index + */ + fieldIndex?: number; + + /** + * The complete path + */ + originalPath: string; +} + +const regex = /([^\/]+)/g; +// const regex = /\/sections\/(.*)\/(.*)\/(.*)/; +const regexShort = /\/sections\/(.*)/; + +/** + * The following method accept an array of section path strings and return a path object + * @param {string | string[]} path + * @returns {SectionErrorPath[]} + */ +const parseSectionErrorPaths = (path: string | string[]): SectionErrorPath[] => { + const paths = typeof path === 'string' ? [path] : path; + + return paths.map((item) => { + if (item.match(regex) && item.match(regex).length > 2) { + return { + sectionId: item.match(regex)[1], + fieldId: item.match(regex)[2], + fieldIndex: hasValue(item.match(regex)[3]) ? +item.match(regex)[3] : 0, + originalPath: item, + }; + } else { + return { + sectionId: item.match(regexShort)[1], + originalPath: item, + }; + } + + } + ); +}; + +export default parseSectionErrorPaths; diff --git a/src/app/submission/utils/parseSectionErrors.ts b/src/app/submission/utils/parseSectionErrors.ts new file mode 100644 index 0000000000..5f2867c8b8 --- /dev/null +++ b/src/app/submission/utils/parseSectionErrors.ts @@ -0,0 +1,27 @@ +import { SubmissionObjectError } from '../../core/submission/models/submission-object.model'; +import { default as parseSectionErrorPaths, SectionErrorPath } from './parseSectionErrorPaths'; + +/** + * the following method accept an array of SubmissionObjectError and return a section errors object + * @param {errors: SubmissionObjectError[]} errors + * @returns {any} + */ +const parseSectionErrors = (errors: SubmissionObjectError[] = []): any => { + const errorsList = Object.create({}); + + errors.forEach((error: SubmissionObjectError) => { + const paths: SectionErrorPath[] = parseSectionErrorPaths(error.paths); + + paths.forEach((path: SectionErrorPath) => { + const sectionError = {path: path.originalPath, message: error.message}; + if (!errorsList[path.sectionId]) { + errorsList[path.sectionId] = []; + } + errorsList[path.sectionId].push(sectionError); + }); + }); + + return errorsList; +}; + +export default parseSectionErrors; diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html index 6855adc860..36f22d932c 100644 --- a/src/app/thumbnail/thumbnail.component.html +++ b/src/app/thumbnail/thumbnail.component.html @@ -1,4 +1,4 @@
    - - + +
    diff --git a/src/app/typings.d.ts b/src/app/typings.d.ts index f3b4a1a548..64067e7249 100644 --- a/src/app/typings.d.ts +++ b/src/app/typings.d.ts @@ -82,3 +82,8 @@ declare module '*.json' { } declare module 'reflect-metadata'; + +declare module '*.scss' { + const content: any; + export default content; +} diff --git a/src/config/auto-sync-config.interface.ts b/src/config/auto-sync-config.interface.ts new file mode 100644 index 0000000000..b737314f56 --- /dev/null +++ b/src/config/auto-sync-config.interface.ts @@ -0,0 +1,30 @@ +import { RestRequestMethod } from '../app/core/data/rest-request-method'; + +/** + * The number of seconds between automatic syncs to the + * server for requests using a certain HTTP Method + */ +type TimePerMethod = { + [method in RestRequestMethod]: number; +}; + +/** + * The config that determines how the automatic syncing + * of changed data to the server works + */ +export interface AutoSyncConfig { + /** + * The number of seconds between automatic syncs to the server + */ + defaultTime: number; + + /** + * HTTP Method specific overrides of defaultTime + */ + timePerMethod: TimePerMethod; + + /** + * The max number of requests in the buffer before a sync to the server + */ + maxBufferSize: number; +} diff --git a/src/config/browse-by-config.interface.ts b/src/config/browse-by-config.interface.ts new file mode 100644 index 0000000000..6adba66b92 --- /dev/null +++ b/src/config/browse-by-config.interface.ts @@ -0,0 +1,21 @@ +import { Config } from './config.interface'; + +/** + * Config that determines how the dropdown list of years are created for browse-by-date components + */ +export interface BrowseByConfig extends Config { + /** + * The max amount of years to display using jumps of one year (current year - oneYearLimit) + */ + oneYearLimit: number; + + /** + * Limit for years to display using jumps of five years (current year - fiveYearLimit) + */ + fiveYearLimit: number; + + /** + * The absolute lowest year to display in the dropdown when no lowest date can be found for all items + */ + defaultLowerLimit: number; +} diff --git a/src/config/cache-config.interface.ts b/src/config/cache-config.interface.ts index f8a2c88640..ef2d19e76e 100644 --- a/src/config/cache-config.interface.ts +++ b/src/config/cache-config.interface.ts @@ -1,6 +1,10 @@ import { Config } from './config.interface'; +import { AutoSyncConfig } from './auto-sync-config.interface'; export interface CacheConfig extends Config { - msToLive: number, - control: string + msToLive: { + default: number; + }, + control: string, + autoSync: AutoSyncConfig } diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index b623a4bf8c..d83ec6e4d8 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -3,7 +3,11 @@ import { ServerConfig } from './server-config.interface'; import { CacheConfig } from './cache-config.interface'; import { UniversalConfig } from './universal-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; +import { SubmissionConfig } from './submission-config.interface'; import { FormConfig } from './form-config.interfaces'; +import {LangConfig} from './lang-config.interface'; +import { BrowseByConfig } from './browse-by-config.interface'; +import { ItemPageConfig } from './item-page-config.interface'; export interface GlobalConfig extends Config { ui: ServerConfig; @@ -12,8 +16,13 @@ export interface GlobalConfig extends Config { cache: CacheConfig; form: FormConfig; notifications: INotificationBoardOptions; + submission: SubmissionConfig; universal: UniversalConfig; gaTrackingId: string; logDirectory: string; debug: boolean; + defaultLanguage: string; + languages: LangConfig[]; + browseBy: BrowseByConfig; + item: ItemPageConfig; } diff --git a/src/config/item-page-config.interface.ts b/src/config/item-page-config.interface.ts new file mode 100644 index 0000000000..c76d2cdb01 --- /dev/null +++ b/src/config/item-page-config.interface.ts @@ -0,0 +1,7 @@ +import { Config } from './config.interface'; + +export interface ItemPageConfig extends Config { + edit: { + undoTimeout: number; + } +} diff --git a/src/config/lang-config.interface.ts b/src/config/lang-config.interface.ts new file mode 100644 index 0000000000..470b50343f --- /dev/null +++ b/src/config/lang-config.interface.ts @@ -0,0 +1,12 @@ +import { Config } from './config.interface'; + +/** + * An interface to represent a language in the configuration. A LangConfig has a code which should be the official + * language code for the language (e.g. ‘fr’), a label which should be the name of the language in that language + * (e.g. ‘Français’), and a boolean to determine whether or not it should be listed in the language select. + */ +export interface LangConfig extends Config { + code: string; + label: string; + active: boolean; +} diff --git a/src/config/submission-config.interface.ts b/src/config/submission-config.interface.ts new file mode 100644 index 0000000000..8b28b2de68 --- /dev/null +++ b/src/config/submission-config.interface.ts @@ -0,0 +1,28 @@ +import { Config } from './config.interface'; + +interface AutosaveConfig extends Config { + metadata: string[], + timer: number +} + +interface IconsConfig extends Config { + metadata: MetadataIconConfig[], + authority: { + confidence: ConfidenceIconConfig[]; + } +} + +export interface MetadataIconConfig extends Config { + name: string, + style: string; +} + +export interface ConfidenceIconConfig extends Config { + value: any, + style: string; +} + +export interface SubmissionConfig extends Config { + autosave: AutosaveConfig, + icons: IconsConfig +} diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index a7a59dc837..b20894880b 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -20,6 +20,8 @@ import { CookieService } from '../../app/shared/services/cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2Module } from 'angulartics2'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { ServerSubmissionService } from '../../app/submission/server-submission.service'; +import { SubmissionService } from '../../app/submission/submission.service'; export const REQ_KEY = makeStateKey('req'); @@ -71,6 +73,10 @@ export function getRequest(transferState: TransferState): any { { provide: CookieService, useClass: ClientCookieService + }, + { + provide: SubmissionService, + useClass: SubmissionService } ] }) diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 246e895006..d809d3cced 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -20,6 +20,8 @@ import { ServerAuthService } from '../../app/core/auth/server-auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from '../../app/shared/mocks/mock-angulartics.service'; +import { SubmissionService } from '../../app/submission/submission.service'; +import { ServerSubmissionService } from '../../app/submission/server-submission.service'; export function createTranslateLoader() { return new TranslateUniversalLoader('dist/assets/i18n/', '.json'); @@ -58,7 +60,11 @@ export function createTranslateLoader() { { provide: CookieService, useClass: ServerCookieService - } + }, + { + provide: SubmissionService, + useClass: ServerSubmissionService + }, ] }) export class ServerAppModule { diff --git a/src/routes.ts b/src/routes.ts index 392d3925a5..f3e963b25a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,10 +1,15 @@ export const ROUTES: string[] = [ 'home', 'items/:id', + 'login', + 'logout', 'collections/:id', 'communities/:id', 'login', 'logout', 'search', + 'submit', + 'workspaceitems/:id/edit', + 'workflowitems/:id/edit', '**' ]; diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index ea89a3a0d5..7be76ff5d3 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -1,7 +1,16 @@ +/** Help Variables **/ +$fa-fixed-width: 1.25rem; +$icon-padding: 1rem; +$collapsed-sidebar-width: calculatePx($fa-fixed-width + (2 * $icon-padding)); +$sidebar-items-width: 250px; +$total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width; + /* Fonts */ $fa-font-path: "../assets/fonts"; /* Images */ $image-path: "../assets/images"; + +/** Bootstrap Variables **/ /* Colors */ $gray-base: #000 !default; $gray-900: lighten($gray-base, 13.5%) !default; // #222 @@ -9,12 +18,14 @@ $gray-800: lighten($gray-base, 26.6%) !default; // #444 $gray-700: lighten($gray-base, 46.6%) !default; // #777 $gray-600: lighten($gray-base, 73.3%) !default; // #bbb $gray-100: lighten($gray-base, 93.5%) !default; // #eee + /* Reassign color vars to semantic color scheme */ -$blue: #2B4E72 !default; -$green: #94BA65 !default; -$cyan: #2790B0 !default; -$yellow: #EBBB54 !default; -$red: #CF4444 !default; +$blue: #2B4E72 !default; +$green: #94BA65 !default; +$cyan: #2790B0 !default; +$yellow: #ec9433 !default; +$red: #CF4444 !default; +$dark: darken($blue, 17%) !default; $theme-colors: ( primary: $blue, @@ -24,14 +35,24 @@ $theme-colors: ( warning: $yellow, danger: $red, light: $gray-100, - dark: $blue + dark: $dark ) !default; /* Fonts */ $link-color: map-get($theme-colors, info) !default; -$navbar-dark-color: rgba(white, .5) !default; -$navbar-light-color: rgba(black, .5) !default; +$navbar-dark-color: rgba(white, .5) !default; +$navbar-light-color: rgba(black, .5) !default; $navbar-dark-toggler-icon-bg: url("data%3Aimage%2Fsvg+xml%3Bcharset%3Dutf8%2C%3Csvg+viewBox%3D%270+0+30+30%27+xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath+stroke%3D%27#{$navbar-dark-color}%27+stroke-width%3D%272%27+stroke-linecap%3D%27round%27+stroke-miterlimit%3D%2710%27+d%3D%27M4+7h22M4+15h22M4+23h22%27%2F%3E%3C%2Fsvg%3E"); $navbar-light-toggler-icon-bg: url("data%3Aimage%2Fsvg+xml%3Bcharset%3Dutf8%2C%3Csvg+viewBox%3D%270+0+30+30%27+xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath+stroke%3D%27#{$navbar-light-color}%27+stroke-width%3D%272%27+stroke-linecap%3D%27round%27+stroke-miterlimit%3D%2710%27+d%3D%27M4+7h22M4+15h22M4+23h22%27%2F%3E%3C%2Fsvg%3E"); $enable-shadows: true !default; + +$grid-breakpoints: ( + xs: 0, + sm: (576px - $collapsed-sidebar-width), + md: (768px - $collapsed-sidebar-width), + lg: (992px - $collapsed-sidebar-width), + xl: (1200px - $collapsed-sidebar-width) +) !default; + +$yiq-contrasted-threshold: 165 !default; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index f378c2b7c9..716002327a 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -1,11 +1,34 @@ $content-spacing: $spacer * 1.5; $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2); + $card-height-percentage:98%; $card-thumbnail-height:240px; $dropdown-menu-max-height: 200px; +$drop-zone-area-height: 42px; $drop-zone-area-z-index: 1025; $drop-zone-area-inner-z-index: 1021; $login-logo-height:72px; $login-logo-width:72px; +$submission-header-z-index: 1001; +$submission-footer-z-index: 1000; +$main-z-index: 0; +$nav-z-index: 10; +$sidebar-z-index: 20; + +$header-logo-height: 80px; +$header-logo-height-xs: 50px; + +$admin-sidebar-bg: $dark; +$admin-sidebar-active-bg: darken($dark, 3%); +$admin-sidebar-header-bg: darken($dark, 7%); + +$dark-scrollbar-background: $admin-sidebar-active-bg; +$dark-scrollbar-foreground: #47495d; + +$submission-sections-margin-bottom: .5rem !default; + +$edit-item-button-min-width: 100px; +$edit-item-metadata-field-width: 190px; +$edit-item-language-field-width: 43px; diff --git a/src/styles/_exposed_variables.scss b/src/styles/_exposed_variables.scss new file mode 100644 index 0000000000..5f0f2d2654 --- /dev/null +++ b/src/styles/_exposed_variables.scss @@ -0,0 +1,12 @@ +@import '_variables.scss'; + +:export { + xlMin: map-get($grid-breakpoints, xl); + mdMin: map-get($grid-breakpoints, md); + lgMin: map-get($grid-breakpoints, lg); + smMin: map-get($grid-breakpoints, sm); + adminSidebarActiveBg: $admin-sidebar-active-bg; + sidebarItemsWidth: $sidebar-items-width; + collapsedSidebarWidth: $collapsed-sidebar-width; + totalSidebarWidth: $total-sidebar-width; +} \ No newline at end of file diff --git a/src/styles/_functions.scss b/src/styles/_functions.scss index 81de954285..4445b89727 100644 --- a/src/styles/_functions.scss +++ b/src/styles/_functions.scss @@ -3,10 +3,16 @@ @return $remSize; } + @function strip-unit($number) { @if type-of($number) == 'number' and not unitless($number) { @return $number / ($number * 0 + 1); } - @return $number; -} \ No newline at end of file +} + + +@function calculatePx($size) { + $pxSize: strip-unit($size) * 16px; + @return $pxSize; +} diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 2155396164..40bb9b8f3e 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -1,15 +1,43 @@ @import '../../node_modules/bootstrap/scss/functions.scss'; @import '../../node_modules/bootstrap/scss/mixins.scss'; -@import '../../node_modules/bootstrap/scss/variables.scss'; +@import 'variables.scss'; @mixin word-wrap() { - overflow-wrap: break-word; - word-wrap: break-word; - -ms-word-break: break-all; - word-break: break-all; - word-break: break-word; - -ms-hyphens: auto; - -moz-hyphens: auto; - -webkit-hyphens: auto; - hyphens: auto; + overflow-wrap: break-word; + word-wrap: break-word; + -ms-word-break: break-all; + word-break: break-all; + word-break: break-word; + -ms-hyphens: auto; + -moz-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +@mixin dark-scrollbar() { + &::-webkit-scrollbar { + width: 8px; + height: 3px; + } + &::-webkit-scrollbar-button { + background-color: $dark-scrollbar-background; + } + &::-webkit-scrollbar-track { + background-color: lighten($dark-scrollbar-background, 2%); + } + &::-webkit-scrollbar-track-piece { + background-color: $dark-scrollbar-background; + } + &::-webkit-scrollbar-thumb { + height: 50px; + background-color: $dark-scrollbar-foreground; + border-radius: 3px; + } + &::-webkit-scrollbar-corner { + background-color: lighten($dark-scrollbar-background, 2%); + } + &::-webkit-resizer { + background-color: $dark-scrollbar-background; + } + } diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 4b5300e3f9..33a3f0b1fa 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -1,6 +1,5 @@ -@import 'bootstrap_variables.scss'; -@import '../../node_modules/font-awesome/scss/variables.scss'; -@import '../../node_modules/bootstrap/scss/functions.scss'; -@import '../../node_modules/bootstrap/scss/variables.scss'; @import '_functions.scss'; +@import '../../node_modules/bootstrap/scss/functions.scss'; +@import 'bootstrap_variables.scss'; +@import '../../node_modules/bootstrap/scss/variables.scss'; @import 'custom_variables.scss'; \ No newline at end of file diff --git a/src/styles/helpers/_font_awesome_imports.scss b/src/styles/helpers/_font_awesome_imports.scss new file mode 100644 index 0000000000..8345d549bc --- /dev/null +++ b/src/styles/helpers/_font_awesome_imports.scss @@ -0,0 +1,5 @@ +@import "../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss"; +@import "../../node_modules/@fortawesome/fontawesome-free/scss/solid.scss"; +@import "../../node_modules/@fortawesome/fontawesome-free/scss/brands.scss"; +@import "../../node_modules/@fortawesome/fontawesome-free/scss/regular.scss"; + diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 8d46668e71..7fb4656a15 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -1,5 +1,5 @@ const CopyWebpackPlugin = require('copy-webpack-plugin'); - +const path = require('path'); const { root, join @@ -37,7 +37,8 @@ module.exports = { { loader: 'css-loader', options: { - sourceMap: true + sourceMap: true, + modules: true } }, { @@ -50,7 +51,9 @@ module.exports = { }, { test: /\.scss$/, - exclude: /node_modules/, + exclude: [/node_modules/, + path.resolve(__dirname, '..', 'src/styles/_exposed_variables.scss') + ], use: [{ loader: 'to-string-loader', options: { @@ -62,12 +65,6 @@ module.exports = { sourceMap: true } }, - { - loader: 'postcss-loader', - options: { - sourceMap: true - } - }, { loader: 'resolve-url-loader', options: { @@ -82,6 +79,15 @@ module.exports = { } ] }, + { + test: /_exposed_variables.scss$/, + exclude: /node_modules/, + use: [{ + loader: "css-loader" // translates CSS into CommonJS + }, { + loader: "sass-loader" // compiles Sass to CSS + }] + }, { test: /\.html$/, loader: 'raw-loader' @@ -90,7 +96,7 @@ module.exports = { }, plugins: [ new CopyWebpackPlugin([{ - from: join(__dirname, '..', 'node_modules', 'font-awesome', 'fonts'), + from: join(__dirname, '..', 'node_modules', '@fortawesome', 'fontawesome-free', 'webfonts'), to: join('assets', 'fonts') }, { from: join(__dirname, '..', 'resources', 'images'), diff --git a/webpack/webpack.prod.js b/webpack/webpack.prod.js index 9b15b464e3..35a683bb96 100644 --- a/webpack/webpack.prod.js +++ b/webpack/webpack.prod.js @@ -2,6 +2,8 @@ const webpack = require('webpack'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const CompressionPlugin = require("compression-webpack-plugin"); const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); +const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); +const cssnano = require("cssnano"); const { root @@ -18,12 +20,6 @@ module.exports = { } }), - // Loader options - new webpack.LoaderOptionsPlugin({ - minimize: true, - debug: false - }), - new BundleAnalyzerPlugin({ analyzerMode: 'disabled', // change it to `server` to view bundle stats reportFilename: 'report.html', @@ -64,6 +60,15 @@ module.exports = { }, sourceMap: true } + }), + new OptimizeCSSAssetsPlugin({ + cssProcessor: cssnano, + cssProcessorOptions: { + discardComments: { + removeAll: true, + } + }, + safe: true }) ] }, diff --git a/webpack/webpack.test.js b/webpack/webpack.test.js index b0305728d3..8c6760e377 100644 --- a/webpack/webpack.test.js +++ b/webpack/webpack.test.js @@ -241,18 +241,6 @@ module.exports = function (options) { 'HMR': false, } }), - - /** - * Plugin LoaderOptionsPlugin (experimental) - * - * See: https://gist.github.com/sokra/27b24881210b56bbaff7 - */ - new LoaderOptionsPlugin({ - debug: false, - options: { - - } - }), new ForkTsCheckerWebpackPlugin() ], diff --git a/yarn.lock b/yarn.lock index ffe2adb3c4..786c9ade3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -147,6 +147,11 @@ esutils "^2.0.2" js-tokens "^4.0.0" +"@fortawesome/fontawesome-free@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.5.0.tgz#0c6c53823d04457ae669cd19567b8a21dbb4fcfd" + integrity sha512-p4lu0jfj5QN013ddArh99r3OXZ/fp9rbovs62LfaO70OMBsAXxtNd0lAq/97fitrscR0fqfd+/a5KNcp6Sh/0A== + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -160,15 +165,15 @@ resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-2.2.2.tgz#07c64badd48b563140eb5a6327b5516bf2226834" integrity sha512-uqngK1urcevQeF+zgoGW1XDnasjoob4QrwhynNUFpDnnplP1wa+BEUjpSccxU+L2dHLfrOb2sPGEGp8cE2X3Iw== -"@ng-dynamic-forms/core@6.0.9": - version "6.0.9" - resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/core/-/core-6.0.9.tgz#358fa6141ec3fa5a22eb2d74f61068dc367cf257" - integrity sha512-kXL+ligvcfogGdqmuH2qeCZYynpQRO9DcSTKMNhBmds937GXpOW2WK7praZcXeUBfq1mWg/SFqsOwI8bOrRlew== +"@ng-dynamic-forms/core@6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/core/-/core-6.2.0.tgz#85cd361d5e54d0fb394e9de9b887f8692d5b4c29" + integrity sha512-c5BzFK7pE6Wo4MdOSrkI6TOtoV3fYSAljKVtyKFR4JRiK6V3yxFiVuY1Ww96urYjhgBzChyqUaDDR6nVUPaYxw== -"@ng-dynamic-forms/ui-ng-bootstrap@6.0.9": - version "6.0.9" - resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/ui-ng-bootstrap/-/ui-ng-bootstrap-6.0.9.tgz#875abefaded1587e976fb4c4cccf743ac25981f0" - integrity sha512-fUqIwxKIEh9UCCQRPXqlTLei6yhc2bXrmKwGGEbIZyvCDY3VJFZyMTOkG0HbROgQhomSY+pBwU1pMT7anvpF2w== +"@ng-dynamic-forms/ui-ng-bootstrap@6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/ui-ng-bootstrap/-/ui-ng-bootstrap-6.2.0.tgz#cd8e65472eaa4c0068d4ab453f84cab9d3b38a1c" + integrity sha512-zyieF2HvUuxqLUA4fYA9OJPMBokJIUjcVnZCPdy4sH/vP6jhMR0mCdvG/WWCn8CNRSgIQUmKTyqv4FKHunJYGw== "@ngrx/effects@^6.1.0": version "6.1.0" @@ -277,6 +282,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/circular-json@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@types/circular-json/-/circular-json-0.4.0.tgz#7401f7e218cfe87ad4c43690da5658b9acaf51be" + integrity sha512-7+kYB7x5a7nFWW1YPBh3KxhwKfiaI4PbZ1RvzBU91LZy7lWJO822CI+pqzSre/DZ7KsCuMKdHnLHHFu8AyXbQg== + "@types/connect@*": version "3.4.32" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28" @@ -324,6 +334,11 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/file-saver@^1.3.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-1.3.1.tgz#55d76b3c78b5fcc8a7588ead428dce0822464120" + integrity sha512-A+lNc0nnhtX3iTLEYd/DisKTZdNKTf1bN0aSfQD/fG8bQ6SfUe5u8Fm2ab8qQHaMY5GVZumAXLnYptwX+mmQgg== + "@types/fs-extra@4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-4.0.0.tgz#1dd742ad5c9bce308f7a52d02ebc01421bc9102f" @@ -406,6 +421,11 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-0.0.32.tgz#bd284e57c84f1325da702babfc82a5328190c0c5" integrity sha1-vShOV8hPEyXacCur/IKlMoGQwMU= +"@types/q@^1.5.1": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" + integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== + "@types/range-parser@*": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.2.tgz#fa8e1ad1d474688a757140c91de6dace6f4abc8d" @@ -431,6 +451,11 @@ dependencies: "@types/node" "*" +"@types/stacktrace-js@^0.0.32": + version "0.0.32" + resolved "https://registry.yarnpkg.com/@types/stacktrace-js/-/stacktrace-js-0.0.32.tgz#d23e4a36a5073d39487fbea8234cc6186862d389" + integrity sha512-SdxmlrHfO0BxgbBP9HZWMUo2rima8lwMjPiWm6S0dyKkDa5CseamktFhXg8umu3TPVBkSlX6ZoB5uUDJK89yvg== + "@types/strip-bom@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" @@ -721,6 +746,11 @@ align-text@^0.1.1, align-text@^0.1.3: longest "^1.0.1" repeat-string "^1.5.2" +alphanum-sort@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" + integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= + amdefine@>=0.0.4, amdefine@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" @@ -1389,7 +1419,7 @@ bonjour@^3.5.0: multicast-dns "^6.0.1" multicast-dns-service-types "^1.1.0" -boolbase@~1.0.0: +boolbase@^1.0.0, boolbase@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= @@ -1408,10 +1438,10 @@ boom@5.x.x: dependencies: hoek "4.x.x" -bootstrap@4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" - integrity sha512-rDFIzgXcof0jDyjNosjv4Sno77X4KuPeFxG2XZZv1/Kc8DRVGVADdoQyyOVDwPqL36DDmtCQbrpMCqvpPLJQ0w== +bootstrap@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.3.1.tgz#280ca8f610504d99d7b6b4bfc4b68cec601704ac" + integrity sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag== boxen@^1.2.1: version "1.3.0" @@ -1538,6 +1568,15 @@ browserslist@^2.0.0, browserslist@^2.11.3: caniuse-lite "^1.0.30000792" electron-to-chromium "^1.3.30" +browserslist@^4.0.0: + version "4.4.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.4.2.tgz#6ea8a74d6464bb0bd549105f659b41197d8f0ba2" + integrity sha512-ISS/AIAiHERJ3d45Fz0AVYKkgcy+F/eJHzKEvv1j0wwKGKD9T3BrwKr/5g45L+Y4XIK5PlTqefHciRFcfE1Jxg== + dependencies: + caniuse-lite "^1.0.30000939" + electron-to-chromium "^1.3.113" + node-releases "^1.1.8" + browserslist@^4.0.2: version "4.1.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.1.0.tgz#81cbb8e52dfa09918f93c6e051d779cb7360785d" @@ -1678,11 +1717,30 @@ call-me-maybe@^1.0.1: resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= + dependencies: + callsites "^2.0.0" + +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= + dependencies: + caller-callsite "^2.0.0" + callsite@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA= +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + camel-case@3.0.x: version "3.0.0" resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" @@ -1729,11 +1787,26 @@ caniuse-api@^2.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" +caniuse-api@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" + integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== + dependencies: + browserslist "^4.0.0" + caniuse-lite "^1.0.0" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000697, caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.30000805, caniuse-lite@^1.0.30000878: version "1.0.30000883" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000883.tgz#597c1eabfb379bd9fbeaa778632762eb574706ac" integrity sha512-ovvb0uya4cKJct8Rj9Olstz0LaWmyJhCp3NawRG5fVigka8pEhIIwipF7zyYd2Q58UZb5YfIt52pVF444uj2kQ== +caniuse-lite@^1.0.30000939: + version "1.0.30000948" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000948.tgz#793ed7c28fe664856beb92b43fc013fc22b81633" + integrity sha512-Lw4y7oz1X5MOMZm+2IFaSISqVVQvUuD+ZUSfeYK/SlYiMjkHN/eJ2PDfJehW5NA6JjrxYSSnIWfwjeObQMEjFQ== + capture-stack-trace@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d" @@ -1779,6 +1852,15 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.4.1: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chardet@^0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2" @@ -1860,6 +1942,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" +circular-json@^0.5.0: + version "0.5.9" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.5.9.tgz#932763ae88f4f7dead7a0d09c8a51a4743a53b1d" + integrity sha512-4ivwqHpIFJZBuhN3g/pEcdbnGUywkBblloGbkglyloVjjR3uT6tieI89MVOfbP2tHX5sgb01FuLgAOzebNlJNQ== + circular-json@^0.5.5: version "0.5.5" resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.5.5.tgz#64182ef359042d37cd8e767fc9de878b1e9447d3" @@ -1951,6 +2038,15 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= +coa@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/coa/-/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" + integrity sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA== + dependencies: + "@types/q" "^1.5.1" + chalk "^2.4.1" + q "^1.1.2" + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -2033,6 +2129,14 @@ color@^2.0.1: color-convert "^1.9.1" color-string "^1.5.2" +color@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.1.0.tgz#d8e9fb096732875774c84bf922815df0308d0ffc" + integrity sha512-CwyopLkuRYO5ei2EpzpIh6LqJMt6Mt+jZhO5VI5f/wJLZriXQE32/SSqzmrh+QB+AZT81Cj8yv+7zwToW8ahZg== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + colors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" @@ -2304,6 +2408,17 @@ cosmiconfig@^4.0.0: parse-json "^4.0.0" require-from-string "^2.0.1" +cosmiconfig@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.1.0.tgz#6c5c35e97f37f985061cdf653f114784231185cf" + integrity sha512-kCNPvthka8gvLtzAxQXvWo4FxqRB+ftRZyPZNuab5ngvM9Y7yw7hbEysglptLgpkGX9nAOKTBVkHUAe8xtYR6Q== + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.9.0" + lodash.get "^4.4.2" + parse-json "^4.0.0" + coveralls@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.0.0.tgz#22ef730330538080d29b8c151dc9146afde88a99" @@ -2445,6 +2560,19 @@ css-color-function@~1.3.3: debug "^3.1.0" rgb "~0.1.0" +css-color-names@0.0.4, css-color-names@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= + +css-declaration-sorter@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" + integrity sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== + dependencies: + postcss "^7.0.1" + timsort "^0.3.0" + css-loader@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-1.0.0.tgz#9f46aaa5ca41dbe31860e3b62b8e23c42916bf56" @@ -2463,6 +2591,11 @@ css-loader@1.0.0: postcss-value-parser "^3.3.0" source-list-map "^2.0.0" +css-select-base-adapter@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" + integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== + css-select@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" @@ -2473,6 +2606,16 @@ css-select@^1.1.0: domutils "1.5.1" nth-check "~1.0.1" +css-select@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.0.2.tgz#ab4386cec9e1f668855564b17c3733b43b2a5ede" + integrity sha512-dSpYaDVoWaELjvZ3mS6IKZM/y2PMPa/XYoEfYNZePL4U/XgyxZNroHEHReDx/d+VgXh9VbCTtFqLkFbmeqeaRQ== + dependencies: + boolbase "^1.0.0" + css-what "^2.1.2" + domutils "^1.7.0" + nth-check "^1.0.2" + css-selector-tokenizer@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz#e6988474ae8c953477bf5e7efecfceccd9cf4c86" @@ -2482,16 +2625,42 @@ css-selector-tokenizer@^0.7.0: fastparse "^1.1.1" regexpu-core "^1.0.0" +css-tree@1.0.0-alpha.28: + version "1.0.0-alpha.28" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.28.tgz#8e8968190d886c9477bc8d61e96f61af3f7ffa7f" + integrity sha512-joNNW1gCp3qFFzj4St6zk+Wh/NBv0vM5YbEreZk0SD4S23S+1xBKb6cLDg2uj4P4k/GUMlIm6cKIDqIG+vdt0w== + dependencies: + mdn-data "~1.1.0" + source-map "^0.5.3" + +css-tree@1.0.0-alpha.29: + version "1.0.0-alpha.29" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.29.tgz#3fa9d4ef3142cbd1c301e7664c1f352bd82f5a39" + integrity sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg== + dependencies: + mdn-data "~1.1.0" + source-map "^0.5.3" + css-unit-converter@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.1.tgz#d9b9281adcfd8ced935bdbaba83786897f64e996" integrity sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY= +css-url-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/css-url-regex/-/css-url-regex-1.1.0.tgz#83834230cc9f74c457de59eebd1543feeb83b7ec" + integrity sha1-g4NCMMyfdMRX3lnuvRVD/uuDt+w= + css-what@2.1: version "2.1.0" resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.0.tgz#9467d032c38cfaefb9f2d79501253062f87fa1bd" integrity sha1-lGfQMsOM+u+58teVASUwYvh/ob0= +css-what@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" + integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== + css@^2.0.0: version "2.2.3" resolved "https://registry.yarnpkg.com/css/-/css-2.2.3.tgz#f861f4ba61e79bedc962aa548e5780fd95cbc6be" @@ -2514,6 +2683,86 @@ cssesc@^0.1.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" integrity sha1-yBSQPkViM3GgR3tAEJqq++6t27Q= +cssesc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-2.0.0.tgz#3b13bd1bb1cb36e1bcb5a4dcd27f54c5dcb35703" + integrity sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg== + +cssnano-preset-default@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" + integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== + dependencies: + css-declaration-sorter "^4.0.1" + cssnano-util-raw-cache "^4.0.1" + postcss "^7.0.0" + postcss-calc "^7.0.1" + postcss-colormin "^4.0.3" + postcss-convert-values "^4.0.1" + postcss-discard-comments "^4.0.2" + postcss-discard-duplicates "^4.0.2" + postcss-discard-empty "^4.0.1" + postcss-discard-overridden "^4.0.1" + postcss-merge-longhand "^4.0.11" + postcss-merge-rules "^4.0.3" + postcss-minify-font-values "^4.0.2" + postcss-minify-gradients "^4.0.2" + postcss-minify-params "^4.0.2" + postcss-minify-selectors "^4.0.2" + postcss-normalize-charset "^4.0.1" + postcss-normalize-display-values "^4.0.2" + postcss-normalize-positions "^4.0.2" + postcss-normalize-repeat-style "^4.0.2" + postcss-normalize-string "^4.0.2" + postcss-normalize-timing-functions "^4.0.2" + postcss-normalize-unicode "^4.0.1" + postcss-normalize-url "^4.0.1" + postcss-normalize-whitespace "^4.0.2" + postcss-ordered-values "^4.1.2" + postcss-reduce-initial "^4.0.3" + postcss-reduce-transforms "^4.0.2" + postcss-svgo "^4.0.2" + postcss-unique-selectors "^4.0.1" + +cssnano-util-get-arguments@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" + integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= + +cssnano-util-get-match@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" + integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= + +cssnano-util-raw-cache@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" + integrity sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== + dependencies: + postcss "^7.0.0" + +cssnano-util-same-parent@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" + integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== + +cssnano@^4.1.0, cssnano@^4.1.10: + version "4.1.10" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2" + integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== + dependencies: + cosmiconfig "^5.0.0" + cssnano-preset-default "^4.0.7" + is-resolvable "^1.0.0" + postcss "^7.0.0" + +csso@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/csso/-/csso-3.5.1.tgz#7b9eb8be61628973c1b261e169d2f024008e758b" + integrity sha512-vrqULLffYU1Q2tLdJvaCYbONStnfkfimRxXNaGjxMldI0C7JPBC4rB1RyjhfdZ4m1frm8pM9uRPKH3d2knZ8gg== + dependencies: + css-tree "1.0.0-alpha.29" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -2627,7 +2876,7 @@ default-gateway@^2.6.0: execa "^0.10.0" ip-regex "^2.1.0" -define-properties@^1.1.2: +define-properties@^1.1.2, define-properties@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== @@ -2850,7 +3099,15 @@ domutils@1.5.1: dom-serializer "0" domelementtype "1" -dot-prop@^4.1.0: +domutils@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-prop@^4.1.0, dot-prop@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== @@ -2912,6 +3169,11 @@ ejs@^2.5.7: resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0" integrity sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ== +electron-to-chromium@^1.3.113: + version "1.3.116" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.116.tgz#1dbfee6a592a0c14ade77dbdfe54fef86387d702" + integrity sha512-NKwKAXzur5vFCZYBHpdWjTMO8QptNLNP80nItkSIgUOapPAo9Uia+RvkCaZJtO7fhQaVElSvBPWEc2ku6cKsPA== + electron-to-chromium@^1.3.30, electron-to-chromium@^1.3.61: version "1.3.62" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.62.tgz#2e8e2dc070c800ec8ce23ff9dfcceb585d6f9ed8" @@ -3027,6 +3289,25 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +error-stack-parser@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.2.tgz#4ae8dbaa2bf90a8b450707b9149dcabca135520d" + integrity sha512-E1fPutRDdIj/hohG0UpT5mayXNCxXP9d+snxFsPU9X0XgccOumKraa3juDMwTUyi7+Bu5+mCGagjg4IYeNbOdw== + dependencies: + stackframe "^1.0.4" + +es-abstract@^1.12.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" + integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg== + dependencies: + es-to-primitive "^1.2.0" + function-bind "^1.1.1" + has "^1.0.3" + is-callable "^1.1.4" + is-regex "^1.0.4" + object-keys "^1.0.12" + es-abstract@^1.4.3, es-abstract@^1.5.1: version "1.12.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.12.0.tgz#9dbbdd27c6856f0001421ca18782d786bf8a6165" @@ -3047,6 +3328,15 @@ es-to-primitive@^1.1.1: is-date-object "^1.0.1" is-symbol "^1.0.1" +es-to-primitive@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" + integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14: version "0.10.46" resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.46.tgz#efd99f67c5a7ec789baa3daa7f79870388f7f572" @@ -3487,6 +3777,13 @@ fast-glob@^2.0.2: merge2 "^1.2.1" micromatch "^3.1.10" +fast-json-patch@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-2.0.7.tgz#55864b08b1e50381d2f37fd472bb2e18fe54a733" + integrity sha512-DQeoEyPYxdTtfmB3yDlxkLyKTdbJ6ABfFGcMynDqjvGhPYLto/pZyb/dG2Nyd/n9CArjEWN9ZST++AFmgzgbGw== + dependencies: + deep-equal "^1.0.1" + fast-json-stable-stringify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" @@ -3530,6 +3827,11 @@ figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" +file-saver@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" + integrity sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg== + filename-regex@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" @@ -4187,7 +4489,7 @@ has-values@^1.0.0: is-number "^3.0.0" kind-of "^4.0.0" -has@^1.0.1: +has@^1.0.0, has@^1.0.1, has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== @@ -4233,6 +4535,11 @@ he@1.1.x, he@^1.1.1: resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0= +hex-color-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" + integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== + highlight.js@^9.0.0: version "9.12.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e" @@ -4274,6 +4581,21 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" +hsl-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" + integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= + +hsla-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" + integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= + +html-comment-regex@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" + integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== + html-entities@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" @@ -4475,6 +4797,14 @@ import-cwd@^2.0.0: dependencies: import-from "^2.1.0" +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + import-from@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" @@ -4636,6 +4966,11 @@ ipaddr.js@^1.5.2: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.1.tgz#fa4b79fa47fd3def5e3b159825161c0a519c9427" integrity sha1-+kt5+kf9Pe9eOxWYJRYcClGclCc= +is-absolute-url@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" + integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -4679,7 +5014,7 @@ is-builtin-module@^1.0.0: dependencies: builtin-modules "^1.0.0" -is-callable@^1.1.1, is-callable@^1.1.3: +is-callable@^1.1.1, is-callable@^1.1.3, is-callable@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== @@ -4691,6 +5026,18 @@ is-ci@^1.0.10: dependencies: ci-info "^1.3.0" +is-color-stop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" + integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= + dependencies: + css-color-names "^0.0.4" + hex-color-regex "^1.1.0" + hsl-regex "^1.0.0" + hsla-regex "^1.0.0" + rgb-regex "^1.0.1" + rgba-regex "^1.0.0" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -4907,6 +5254,11 @@ is-regex@^1.0.4: dependencies: has "^1.0.1" +is-resolvable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" + integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== + is-retry-allowed@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" @@ -4917,11 +5269,25 @@ is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= +is-svg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75" + integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== + dependencies: + html-comment-regex "^1.1.0" + is-symbol@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.1.tgz#3cc59f00025194b6ab2e38dbae6689256b660572" integrity sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI= +is-symbol@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" + integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== + dependencies: + has-symbols "^1.0.0" + is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -5119,6 +5485,14 @@ js-yaml@3.x, js-yaml@^3.6.1, js-yaml@^3.7.0, js-yaml@^3.9.0: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^3.12.0: + version "3.12.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.2.tgz#ef1d067c5a9d9cb65bd72f285b5d8105c77f14fc" + integrity sha512-QHn/Lh/7HhZ/Twc7vJYQTkjuCa0kaCcDcjK5Zlk2rvnUpy7DxMJ23+Jc2dcyvltwQVg1nygAVlB2oRDFHoRS5Q== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + js.clone@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/js.clone/-/js.clone-0.0.3.tgz#f378d2bf501fcf648074fd91893f4718236bb79c" @@ -5399,6 +5773,14 @@ klaw@^1.0.0: optionalDependencies: graceful-fs "^4.1.9" +last-call-webpack-plugin@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" + integrity sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w== + dependencies: + lodash "^4.17.5" + webpack-sources "^1.1.0" + latest-version@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" @@ -5627,6 +6009,11 @@ lodash.escape@^3.0.0: dependencies: lodash._root "^3.0.0" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + lodash.isarguments@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" @@ -5896,6 +6283,11 @@ md5@^2.2.1: crypt "~0.0.1" is-buffer "~1.1.1" +mdn-data@~1.1.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-1.1.4.tgz#50b5d4ffc4575276573c4eedb8780812a8419f01" + integrity sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -6126,7 +6518,7 @@ mixin-object@^2.0.1: for-in "^0.1.3" is-extendable "^0.1.1" -mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0: +mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= @@ -6259,10 +6651,10 @@ ngrx-store-freeze@^0.2.4: dependencies: deep-freeze-strict "^1.1.1" -ngx-bootstrap@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ngx-bootstrap/-/ngx-bootstrap-3.0.1.tgz#e98d2fc6340f32a9d358cd08e8fda7dcb23bdab3" - integrity sha512-ni91yYtn8ldgf/pxrlwl9lkVcLURGzopSpJnEbbgG1v1EZWTobI8y7J3mx4Kxptkn0EeiQwnLel67G7XJSox4A== +ngx-bootstrap@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ngx-bootstrap/-/ngx-bootstrap-3.2.0.tgz#ece7c48af0bc260462c3f77de14f22d4b3dde149" + integrity sha512-oLSLIWZgRiIfcuxyXLMZUOhX3wZtg6lpuMbdo/0UzMDg2bSOe1XPskcKZ/iuOa3FOlU9rjuYMzswHYYV5f/QCA== ngx-infinite-scroll@6.0.1: version "6.0.1" @@ -6378,10 +6770,17 @@ node-releases@^1.0.0-alpha.11: dependencies: semver "^5.3.0" -node-sass@^4.7.2: - version "4.9.3" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.9.3.tgz#f407cf3d66f78308bb1e346b24fa428703196224" - integrity sha512-XzXyGjO+84wxyH7fV6IwBOTrEBe2f0a6SBze9QWWYR/cL74AcQUks2AsqcCZenl/Fp/JVbuEaLpgrLtocwBUww== +node-releases@^1.1.8: + version "1.1.10" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.10.tgz#5dbeb6bc7f4e9c85b899e2e7adcc0635c9b2adf7" + integrity sha512-KbUPCpfoBvb3oBkej9+nrU0/7xPlVhmhhUJ1PZqwIP5/1dJkRWKWD3OONjo6M2J7tSCBtDCumLwwqeI+DWWaLQ== + dependencies: + semver "^5.3.0" + +node-sass@^4.11.0: + version "4.11.0" + resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.11.0.tgz#183faec398e9cbe93ba43362e2768ca988a6369a" + integrity sha512-bHUdHTphgQJZaF1LASx0kAviPH7sGlcyNhWade4eVIpFp6tsn7SV8xNMTbsQFpEV9VXpnwTTnNYlfsZXgGgmkA== dependencies: async-foreach "^0.1.3" chalk "^1.1.1" @@ -6398,7 +6797,7 @@ node-sass@^4.7.2: nan "^2.10.0" node-gyp "^3.8.0" npmlog "^4.0.0" - request "2.87.0" + request "^2.88.0" sass-graph "^2.2.4" stdout-stream "^1.4.0" "true-case-path" "^1.0.2" @@ -6463,6 +6862,11 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= +normalize-url@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" + integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== + nouislider@^11.0.0: version "11.1.0" resolved "https://registry.yarnpkg.com/nouislider/-/nouislider-11.1.0.tgz#1768eb5b854917325d41b96f2dc4eb3757d73381" @@ -6542,6 +6946,13 @@ npm-run-path@^2.0.0: gauge "~2.7.3" set-blocking "~2.0.0" +nth-check@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + nth-check@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.1.tgz#9929acdf628fc2c41098deab82ac580cf149aae4" @@ -6648,6 +7059,16 @@ object.pick@^1.3.0: dependencies: isobject "^3.0.1" +object.values@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.0.tgz#bf6810ef5da3e5325790eaaa2be213ea84624da9" + integrity sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.12.0" + function-bind "^1.1.1" + has "^1.0.3" + obuf@^1.0.0, obuf@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" @@ -6729,6 +7150,14 @@ optimist@0.6.x, optimist@^0.6.1, optimist@~0.6.0: minimist "~0.0.1" wordwrap "~0.0.2" +optimize-css-assets-webpack-plugin@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-5.0.1.tgz#9eb500711d35165b45e7fd60ba2df40cb3eb9159" + integrity sha512-Rqm6sSjWtx9FchdP0uzTQDc7GXDKnwVEGoSxjezPkzMewx7gEWE9IMUYKmigTRC4U3RaNSwYVnUDLuIdtTpm0A== + dependencies: + cssnano "^4.1.0" + last-call-webpack-plugin "^3.0.0" + optionator@^0.8.1: version "0.8.2" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" @@ -7154,6 +7583,16 @@ postcss-calc@^6.0.0: postcss-selector-parser "^2.2.2" reduce-css-calc "^2.0.0" +postcss-calc@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.1.tgz#36d77bab023b0ecbb9789d84dcb23c4941145436" + integrity sha512-oXqx0m6tb4N3JGdmeMSc/i91KppbYsFZKdH0xMOqK8V1rJlzrKlTdokz8ozUXLVejydRN6u2IddxpcijRj2FqQ== + dependencies: + css-unit-converter "^1.1.1" + postcss "^7.0.5" + postcss-selector-parser "^5.0.0-rc.4" + postcss-value-parser "^3.3.1" + postcss-cli@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/postcss-cli/-/postcss-cli-6.0.0.tgz#688e3750735a7bc21ea6e2f0e427f5f873c16f0b" @@ -7245,6 +7684,25 @@ postcss-color-rgba-fallback@^3.0.0: postcss-value-parser "^3.3.0" rgb-hex "^2.1.0" +postcss-colormin@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" + integrity sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== + dependencies: + browserslist "^4.0.0" + color "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-convert-values@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" + integrity sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + postcss-cssnext@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/postcss-cssnext/-/postcss-cssnext-3.1.0.tgz#927dc29341a938254cde38ea60a923b9dfedead9" @@ -7305,6 +7763,34 @@ postcss-custom-selectors@^4.0.1: postcss "^6.0.1" postcss-selector-matches "^3.0.0" +postcss-discard-comments@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" + integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== + dependencies: + postcss "^7.0.0" + +postcss-discard-duplicates@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" + integrity sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== + dependencies: + postcss "^7.0.0" + +postcss-discard-empty@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" + integrity sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== + dependencies: + postcss "^7.0.0" + +postcss-discard-overridden@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" + integrity sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== + dependencies: + postcss "^7.0.0" + postcss-font-family-system-ui@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/postcss-font-family-system-ui/-/postcss-font-family-system-ui-3.0.0.tgz#675fe7a9e029669f05f8dba2e44c2225ede80623" @@ -7365,11 +7851,73 @@ postcss-media-query-parser@^0.2.3: resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244" integrity sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ= +postcss-merge-longhand@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" + integrity sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== + dependencies: + css-color-names "0.0.4" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + stylehacks "^4.0.0" + +postcss-merge-rules@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" + integrity sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + cssnano-util-same-parent "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + vendors "^1.0.0" + postcss-message-helpers@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e" integrity sha1-pPL0+rbk/gAvCu0ABHjN9S+bpg4= +postcss-minify-font-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" + integrity sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-gradients@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" + integrity sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + is-color-stop "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-params@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" + integrity sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== + dependencies: + alphanum-sort "^1.0.0" + browserslist "^4.0.0" + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + uniqs "^2.0.0" + +postcss-minify-selectors@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" + integrity sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== + dependencies: + alphanum-sort "^1.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + postcss-modules-extract-imports@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85" @@ -7408,6 +7956,96 @@ postcss-nesting@^4.0.1: dependencies: postcss "^6.0.11" +postcss-normalize-charset@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" + integrity sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== + dependencies: + postcss "^7.0.0" + +postcss-normalize-display-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" + integrity sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-positions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" + integrity sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== + dependencies: + cssnano-util-get-arguments "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-repeat-style@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" + integrity sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-string@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" + integrity sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== + dependencies: + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-timing-functions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" + integrity sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-unicode@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" + integrity sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-url@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" + integrity sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== + dependencies: + is-absolute-url "^2.0.0" + normalize-url "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-whitespace@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" + integrity sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-ordered-values@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" + integrity sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== + dependencies: + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + postcss-pseudo-class-any-link@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-4.0.0.tgz#9152a0613d3450720513e8892854bae42d0ee68e" @@ -7423,6 +8061,26 @@ postcss-pseudoelements@^5.0.0: dependencies: postcss "^6.0.0" +postcss-reduce-initial@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" + integrity sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + +postcss-reduce-transforms@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" + integrity sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== + dependencies: + cssnano-util-get-match "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + postcss-replace-overflow-wrap@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-2.0.0.tgz#794db6faa54f8db100854392a93af45768b4e25b" @@ -7487,6 +8145,24 @@ postcss-selector-parser@^2.2.2, postcss-selector-parser@^2.2.3: indexes-of "^1.0.1" uniq "^1.0.1" +postcss-selector-parser@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865" + integrity sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU= + dependencies: + dot-prop "^4.1.1" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^5.0.0-rc.4: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz#249044356697b33b64f1a8f7c80922dddee7195c" + integrity sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ== + dependencies: + cssesc "^2.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + postcss-smart-import@0.7.6: version "0.7.6" resolved "https://registry.yarnpkg.com/postcss-smart-import/-/postcss-smart-import-0.7.6.tgz#259deb84aa28f138458218ecc0e9a84c61ada6a4" @@ -7504,6 +8180,30 @@ postcss-smart-import@0.7.6: resolve "^1.5.0" sugarss "^1.0.1" +postcss-svgo@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258" + integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== + dependencies: + is-svg "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + svgo "^1.0.0" + +postcss-unique-selectors@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" + integrity sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== + dependencies: + alphanum-sort "^1.0.0" + postcss "^7.0.0" + uniqs "^2.0.0" + +postcss-value-parser@^3.0.0, postcss-value-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== + postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15" @@ -7536,6 +8236,15 @@ postcss@^7.0.0, postcss@^7.0.2: source-map "^0.6.1" supports-color "^5.4.0" +postcss@^7.0.1, postcss@^7.0.5: + version "7.0.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.14.tgz#4527ed6b1ca0d82c53ce5ec1a2041c2346bbd6e5" + integrity sha512-NsbD6XUUMZvBxtQAJuWDJeeC4QFsmWsfozWxCJPWf3M55K9iu2iMDaKqyoOdTJ1R4usBXuxlVFAIo8rZPQD4Bg== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -7723,7 +8432,7 @@ q@1.4.1: resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" integrity sha1-VXBbzZPF82c1MMLCy8DCs63cKG4= -q@^1.4.1: +q@^1.1.2, q@^1.4.1: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= @@ -8142,33 +8851,7 @@ request@2.85.0: tunnel-agent "^0.6.0" uuid "^3.1.0" -request@2.87.0: - version "2.87.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.87.0.tgz#32f00235cd08d482b4d0d68db93a829c0ed5756e" - integrity sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.6.0" - caseless "~0.12.0" - combined-stream "~1.0.5" - extend "~3.0.1" - forever-agent "~0.6.1" - form-data "~2.3.1" - har-validator "~5.0.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.17" - oauth-sign "~0.8.2" - performance-now "^2.1.0" - qs "~6.5.1" - safe-buffer "^5.1.1" - tough-cookie "~2.3.3" - tunnel-agent "^0.6.0" - uuid "^3.1.0" - -request@^2.74.0, request@^2.79.0, request@^2.81.0, request@^2.87.0: +request@^2.74.0, request@^2.79.0, request@^2.81.0, request@^2.87.0, request@^2.88.0: version "2.88.0" resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== @@ -8299,11 +8982,21 @@ rgb-hex@^2.1.0: resolved "https://registry.yarnpkg.com/rgb-hex/-/rgb-hex-2.1.0.tgz#c773c5fe2268a25578d92539a82a7a5ce53beda6" integrity sha1-x3PF/iJoolV42SU5qCp6XOU77aY= +rgb-regex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" + integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= + rgb@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/rgb/-/rgb-0.1.0.tgz#be27b291e8feffeac1bd99729721bfa40fc037b5" integrity sha1-vieykej+/+rBvZlylyG/pA/AN7U= +rgba-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" + integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= + right-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" @@ -8401,6 +9094,17 @@ rx@^4.1.0: resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I= +rxjs-spy@^7.5.1: + version "7.5.1" + resolved "https://registry.yarnpkg.com/rxjs-spy/-/rxjs-spy-7.5.1.tgz#1a9ef50bc8d7dd00d9ecf3c54c00929231eaf319" + integrity sha512-dJ9mO4HvW2r16PsU15Qsc0RVkG7pFrfyCNTGx3vrxWje3kIgZ6QjMVnWblQxbniZ32lwLk/2x9+D2O6GhgXV/w== + dependencies: + "@types/circular-json" "^0.4.0" + "@types/stacktrace-js" "^0.0.32" + circular-json "^0.5.0" + error-stack-parser "^2.0.1" + stacktrace-gps "^3.0.2" + rxjs@6.2.2, rxjs@^6.0.0, rxjs@^6.1.0: version "6.2.2" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.2.2.tgz#eb75fa3c186ff5289907d06483a77884586e1cf9" @@ -8459,7 +9163,7 @@ saucelabs@^1.5.0: dependencies: https-proxy-agent "^2.2.1" -sax@>=0.6.0, sax@^1.2.4: +sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -8909,6 +9613,11 @@ source-map@0.5.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.0.tgz#0fe96503ac86a5adb5de63f4e412ae4872cdbe86" integrity sha1-D+llA6yGpa213mP05BKuSHLNvoY= +source-map@0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= + source-map@0.7.3: version "0.7.3" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" @@ -8928,7 +9637,7 @@ source-map@^0.4.2, source-map@^0.4.4: dependencies: amdefine ">=0.0.4" -source-map@^0.5.1, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1: +source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= @@ -9048,6 +9757,24 @@ ssri@^5.2.4: dependencies: safe-buffer "^5.1.1" +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + +stackframe@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.0.4.tgz#357b24a992f9427cba6b545d96a14ed2cbca187b" + integrity sha512-to7oADIniaYwS3MhtCa/sQhrxidCCQiF/qp4/m5iN3ipf0Y7Xlri0f6eG29r08aL7JYl8n32AF3Q5GYBZ7K8vw== + +stacktrace-gps@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.2.tgz#33f8baa4467323ab2bd1816efa279942ba431ccc" + integrity sha512-9o+nWhiz5wFnrB3hBHs2PTyYrS60M1vvpSzHxwxnIbtY2q9Nt51hZvhrG1+2AxD374ecwyS+IUwfkHRE/2zuGg== + dependencies: + source-map "0.5.6" + stackframe "^1.0.4" + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -9221,6 +9948,15 @@ strip-json-comments@^2.0.0, strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +stylehacks@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" + integrity sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + sugarss@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-1.0.1.tgz#be826d9003e0f247735f92365dc3fd7f1bae9e44" @@ -9247,6 +9983,33 @@ supports-color@^5.1.0, supports-color@^5.2.0, supports-color@^5.3.0, supports-co dependencies: has-flag "^3.0.0" +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + +svgo@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.2.0.tgz#305a8fc0f4f9710828c65039bb93d5793225ffc3" + integrity sha512-xBfxJxfk4UeVN8asec9jNxHiv3UAMv/ujwBWGYvQhhMb2u3YTGKkiybPcLFDLq7GLLWE9wa73e0/m8L5nTzQbw== + dependencies: + chalk "^2.4.1" + coa "^2.0.2" + css-select "^2.0.0" + css-select-base-adapter "^0.1.1" + css-tree "1.0.0-alpha.28" + css-url-regex "^1.1.0" + csso "^3.5.1" + js-yaml "^3.12.0" + mkdirp "~0.5.1" + object.values "^1.1.0" + sax "~1.2.4" + stable "^0.1.8" + unquote "~1.1.1" + util.promisify "~1.0.0" + symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" @@ -9366,6 +10129,11 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +timsort@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" + integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= + tmp@0.0.30: version "0.0.30" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.30.tgz#72419d4a8be7d6ce75148fd8b324e593a711c2ed" @@ -9730,6 +10498,11 @@ uniq@^1.0.1: resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= +uniqs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" + integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= + unique-filename@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3" @@ -9769,6 +10542,11 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= +unquote@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" + integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= + unset-value@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" @@ -9878,7 +10656,7 @@ util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= -util.promisify@1.0.0: +util.promisify@1.0.0, util.promisify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" integrity sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== @@ -9962,6 +10740,11 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= +vendors@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.2.tgz#7fcb5eef9f5623b156bcea89ec37d63676f21801" + integrity sha512-w/hry/368nO21AN9QljsaIhb9ZiZtZARoVH5f3CsFbawdLdayCgKRPup7CggujvySMxx0I91NOyxdVENohprLQ== + verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"