diff --git a/README.md b/README.md index 8f2320dbf3270f74ecc97c145d1315d120f92e6c..cb2f41130fbc341778c11413763b94432457d67f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [h Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`** +**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) >= `v1.x`** ```bash # clone the repo @@ -65,7 +65,7 @@ Requirements ------------ - [Node.js](https://nodejs.org), [npm](https://www.npmjs.com/), and [yarn](https://yarnpkg.com) -- Ensure you're running node >= `v5.x`, npm >= `v3.x` and yarn >= `v0.20.x` +- Ensure you're running node >= `v8.x`, npm >= `v5.x` and yarn >= `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. diff --git a/config/environment.default.js b/config/environment.default.js index f70f132fa455bae0655392a4763eecc60fb0e457..804d80b0f20f9a3aa6b28eb02e854ebaca8dcd10 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -10,7 +10,7 @@ module.exports = { // The REST API server settings. rest: { ssl: true, - host: 'dspace7.4science.it', + host: 'dspace7.4science.cloud', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: '/dspace-spring-rest/api' diff --git a/package.json b/package.json index 1f75da6c8be8a3cc81733b90e9f7b97094857657..cc687ea269e6f470a09b5b26deaa7c4ce590d168 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "prebuild": "yarn run clean:dist", "prebuild:aot": "yarn run prebuild", "prebuild:prod": "yarn run prebuild", - "build": "webpack --progress --mode development", - "build:aot": "webpack --env.aot --env.server --mode development && webpack --env.aot --env.client --mode development", - "build:prod": "webpack --env.aot --env.server --mode production && webpack --env.aot --env.client --mode production", + "build": "node ./webpack/run-webpack.js --progress --mode development", + "build:aot": "node ./webpack/run-webpack.js --env.aot --env.server --mode development && node ./webpack/run-webpack.js --env.aot --env.client --mode development", + "build:prod": "node ./webpack/run-webpack.js --env.aot --env.server --mode production && node ./webpack/run-webpack.js --env.aot --env.client --mode production", "postbuild:prod": "yarn run rollup", "rollup": "rollup -c rollup.config.js", "prestart": "yarn run build:prod", @@ -40,7 +40,7 @@ "server": "node dist/server.js", "server:watch": "nodemon dist/server.js", "server:watch:debug": "nodemon --debug dist/server.js", - "webpack:watch": "webpack -w --mode development", + "webpack:watch": "node ./webpack/run-webpack.js -w --mode development", "watch": "yarn run build && npm-run-all -p webpack:watch server:watch", "watch:debug": "yarn run build && npm-run-all -p webpack:watch server:watch:debug", "predebug": "yarn run build", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 875a624c7e4b23af6e76e15dabe43e13a12778af..4260568a7e2891468501e5a2f1a6f8e9d17a4f07 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -91,12 +91,14 @@ }, "item": { "page": { - "author": "Author", + "author": "Authors", "abstract": "Abstract", "date": "Date", "uri": "URI", "files": "Files", "collections": "Collections", + "subject": "Keywords", + "citation": "Citation", "filesection": { "download": "Download", "name": "Name:", @@ -270,6 +272,114 @@ } } }, + "relationships": { + "isPublicationOf": "Publications", + "isProjectOf": "Research Projects", + "isOrgUnitOf": "Organizational Units", + "isAuthorOf": "Authors", + "isPersonOf": "Authors", + "isJournalOf": "Journals", + "isSingleJournalOf": "Journal", + "isVolumeOf": "Journal Volumes", + "isSingleVolumeOf": "Journal Volume", + "isIssueOf": "Journal Issues", + "isJournalIssueOf": "Journal Issue", + "isPublicationOfJournalIssue": "Articles" + }, + "person": { + "page": { + "titleprefix": "Person: ", + "jobtitle": "Job Title", + "lastname": "Last Name", + "firstname": "First Name", + "email": "Email Address", + "orcid": "ORCID", + "birthdate": "Birth Date", + "staffid": "Staff ID", + "link": { + "full": "Show all metadata" + } + }, + "listelement": { + "badge": "Person" + } + }, + "project": { + "page": { + "titleprefix": "Research Project: ", + "status": "Status", + "contributor": "Contributors", + "funder": "Funders", + "id": "ID", + "expectedcompletion": "Expected Completion", + "description": "Description", + "keyword": "Keywords" + }, + "listelement": { + "badge": "Research Project" + } + }, + "orgunit": { + "page": { + "titleprefix": "Organizational Unit: ", + "dateestablished": "Date established", + "city": "City", + "country": "Country", + "id": "ID", + "description": "Description" + }, + "listelement": { + "badge": "Organizational Unit" + } + }, + "journal": { + "page": { + "titleprefix": "Journal: ", + "issn": "ISSN", + "publisher": "Publisher", + "description": "Description", + "editor": "Editor-in-Chief" + }, + "listelement": { + "badge": "Journal" + } + }, + "journalvolume": { + "page": { + "titleprefix": "Journal Volume: ", + "volume": "Volume", + "issuedate": "Issue Date", + "description": "Description" + }, + "listelement": { + "badge": "Journal Volume" + } + }, + "journalissue": { + "page": { + "titleprefix": "Journal Issue: ", + "number": "Number", + "issuedate": "Issue Date", + "description": "Description", + "keyword": "Keywords", + "journal-title": "Journal Title", + "journal-issn": "Journal ISSN" + }, + "listelement": { + "badge": "Journal Issue" + } + }, + "publication": { + "page": { + "titleprefix": "Publication: ", + "journal-title": "Journal Title", + "journal-issn": "Journal ISSN", + "volume-title": "Volume Title" + }, + "listelement": { + "badge": "Publication" + } + }, "nav": { "browse": { "header": "All of DSpace" @@ -282,6 +392,7 @@ }, "login": "Log In", "logout": "Log Out", + "mydspace": "MyDSpace", "language": "Language switch", "search": "Search" }, @@ -318,12 +429,82 @@ "help": "Select a community to browse its collections." } }, + "mydspace": { + "title": "MyDSpace", + "description": "", + "new-submission": "New submission", + "results": { + "head": "Your submissions", + "no-results": "There were no items to show", + "no-title": "No title", + "no-authors": "No Authors", + "no-date": "No Date", + "no-abstract": "No Abstract", + "no-files": "No Files", + "no-uri": "No Uri", + "no-collections": "No Collections" + }, + "messages": { + "title": "Messages", + "to": "To", + "hide-msg": "Hide message", + "show-msg": "Show message", + "no-messages": "No messages yet.", + "no-content": "No content.", + "send-btn": "Send", + "subject-placeholder": "Subject...", + "description-placeholder": "Insert your message here...", + "mark-as-read": "Mark as read", + "mark-as-unread": "Mark as unread", + "submitter-help": "Select this option to send a message to controller.", + "controller-help": "Select this option to send a message to item's submitter." + }, + "show": { + "workspace": "Your Submissions", + "workflow": "All tasks" + }, + "status": { + "workflow": "Workflow", + "validation": "Validation", + "waiting-for-controller": "Waiting for controller", + "workspace": "Workspace", + "archived": "Archived" + }, + "view-btn": "View", + "general": { + "text-here": "HERE" + }, + "upload": { + "upload-successful": "New workspace item created. Click {{here}} for edit it.", + "upload-multiple-successful": "{{qty}} new workspace items created.", + "upload-failed": "Error creating new workspace. Please verify the content uploaded before retry." + } + }, "search": { + "journal": { + "title": "DSpace Angular :: Journal Search", + "results": { + "head": "Journal Search Results" + } + }, + "person": { + "title": "DSpace Angular :: Person Search", + "results": { + "head": "Person Search Results" + } + }, + "publication": { + "title": "DSpace Angular :: Publication Search", + "results": { + "head": "Publication Search Results" + } + }, "title": "DSpace Angular :: Search", "description": "", "form": { "search": "Search", - "search_dspace": "Search DSpace" + "search_dspace": "Search DSpace", + "search_mydspace": "Search MyDSpace" }, "results": { "head": "Search Results", @@ -343,9 +524,13 @@ "rpp": "Results per page" } }, + "switch-configuration": { + "title":"Show" + }, "view-switch": { "show-list": "Show as list", - "show-grid": "Show as grid" + "show-grid": "Show as grid", + "show-detail": "Show detail" }, "filters": { "head": "Filters", @@ -355,7 +540,12 @@ "f.dateIssued.min": "Start date", "f.dateIssued.max": "End date", "f.subject": "Subject", - "f.has_content_in_original_bundle": "Has files" + "f.has_content_in_original_bundle": "Has files", + "f.entityType": "Item Type", + "f.namedresourcetype": "Status", + "f.dateSubmitted": "Date submitted", + "f.itemtype": "Type", + "f.submitter": "Submitter" }, "filter": { "show-more": "Show more", @@ -383,6 +573,30 @@ }, "has_content_in_original_bundle": { "head": "Has files" + }, + "entityType": { + "placeholder": "Item Type", + "head": "Item Type" + }, + "namedresourcetype": { + "placeholder": "Status", + "head": "Status" + }, + "dateSubmitted": { + "placeholder": "Date submitted", + "head": "Date submitted" + }, + "itemtype": { + "placeholder": "Type", + "head": "Type" + }, + "submitter": { + "placeholder": "Submitter", + "head": "Submitter" + }, + "objectpeople": { + "placeholder": "People", + "head": "People" } } } @@ -603,6 +817,7 @@ "item": "Loading item...", "objects": "Loading...", "search-results": "Loading search results...", + "mydspace-results": "Loading items...", "browse-by": "Loading items...", "browse-by-page": "Loading page..." }, @@ -794,6 +1009,49 @@ } } } + }, + "workflow": { + "generic": { + "delete": "Delete", + "delete-help": "If you would to discard this item, select \"Delete\". You will then be asked to confirm it.", + "edit": "Edit", + "edit-help": "Select this option to change the item's metadata.", + "view": "View", + "view-help": "Select this option to view the item's metadata." + }, + "tasks": { + "generic": { + "processing": "Processing...", + "success": "Operation successful", + "error": "Error occurred during operation...", + "submitter": "Submitter" + }, + "claimed": { + "approve": "Approve", + "approve_help": "If you have reviewed the item and it is suitable for inclusion in the collection, select \"Approve\".", + "edit": "Edit", + "edit_help": "Select this option to change the item's metadata.", + "reject": { + "submit": "Reject", + "reason": { + "submit": "Reject item", + "title": "Reason", + "info": "Please enter your reason for rejecting the submission into the box below, indicating whether the submitter may fix a problem and resubmit.", + "placeholder": "Describe the reason of reject" + } + }, + "reject_help": "If you have reviewed the item and found it is <strong>not</strong> suitable for inclusion in the collection, select \"Reject\". You will then be asked to enter a message indicating why the item is unsuitable, and whether the submitter should change something and resubmit.", + "return": "Return to pool", + "return_help": "Return the task to the pool so that another user may perform the task." + + }, + "pool": { + "claim": "Claim", + "claim_help": "Assign this task to yourself.", + "show-detail": "Show detail", + "hide-detail": "Hide detail" + } + } } }, "uploader": { diff --git a/resources/images/orgunit-placeholder.svg b/resources/images/orgunit-placeholder.svg new file mode 100644 index 0000000000000000000000000000000000000000..1dae3d607ebaa2ba51f09af94dcecdc6cd0e6347 --- /dev/null +++ b/resources/images/orgunit-placeholder.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="Layer_1" focusable="false" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + x="0px" y="0px" width="125.348px" height="161.348px" viewBox="62.326 80.326 125.348 161.348" + enable-background="new 62.326 80.326 125.348 161.348" xml:space="preserve"> +<rect x="62.5" y="80.5" fill="#FFFFFF" stroke="#000000" stroke-width="0.3478" stroke-miterlimit="10" width="125" height="161"/> +<path fill="#43515F" d="M135.5,171.5h-21c-2.899,0-5.25,2.352-5.25,5.25v21c0,2.898,2.351,5.25,5.25,5.25h21 + c2.898,0,5.25-2.352,5.25-5.25v-21C140.75,173.852,138.398,171.5,135.5,171.5z M104,124.25c0-2.899-2.351-5.25-5.25-5.25h-21 + c-2.899,0-5.25,2.351-5.25,5.25v21c0,2.899,2.351,5.25,5.25,5.25h15.704l12.002,21.007c1.822-3.127,5.171-5.257,9.043-5.257h0.046 + L104,147.794V140h36.75v-10.5H104V124.25z M172.25,119h-21c-2.898,0-5.25,2.351-5.25,5.25v21c0,2.899,2.352,5.25,5.25,5.25h21 + c2.898,0,5.25-2.351,5.25-5.25v-21C177.5,121.351,175.148,119,172.25,119z"/> +</svg> diff --git a/resources/images/person-placeholder.svg b/resources/images/person-placeholder.svg new file mode 100644 index 0000000000000000000000000000000000000000..bbe84ec8455f1e8419e6e6f3119182f0f14f4d37 --- /dev/null +++ b/resources/images/person-placeholder.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="Layer_1" focusable="false" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + x="0px" y="0px" width="125.348px" height="161.348px" viewBox="62.674 80.674 125.348 161.348" + enable-background="new 62.674 80.674 125.348 161.348" xml:space="preserve"> +<rect x="62.847" y="80.847" fill="#FFFFFF" stroke="#000000" stroke-width="0.3478" stroke-miterlimit="10" width="125" height="161"/> +<path fill="#43515F" d="M125.347,167.91c16.304,0,29.531-13.228,29.531-29.531c0-16.303-13.228-29.531-29.531-29.531 + c-16.303,0-29.531,13.227-29.531,29.531S109.044,167.91,125.347,167.91z M151.597,174.472h-11.301 + c-4.552,2.092-9.617,3.281-14.95,3.281c-5.332,0-10.377-1.189-14.95-3.281h-11.3c-14.499,0-26.25,11.751-26.25,26.25v3.281 + c0,5.435,4.409,9.844,9.844,9.844h85.312c5.435,0,9.844-4.408,9.844-9.844v-3.281C177.847,186.223,166.096,174.472,151.597,174.472z + "/> +</svg> diff --git a/resources/images/project-placeholder.svg b/resources/images/project-placeholder.svg new file mode 100644 index 0000000000000000000000000000000000000000..75ce1003fea407fa5a6f9384d1ecc12c8f85157f --- /dev/null +++ b/resources/images/project-placeholder.svg @@ -0,0 +1,118 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + width="125.5px" height="161.5px" viewBox="62.25 80.25 125.5 161.5" enable-background="new 62.25 80.25 125.5 161.5" + xml:space="preserve"> +<rect x="62.5" y="80.5" fill="#FFFFFF" stroke="#000000" stroke-width="0.5" stroke-miterlimit="10" width="125" height="161"/> +<g> + <path fill="#43515F" d="M176.007,108.235h-65.078c-0.972,0-1.759,0.788-1.759,1.759v7.035h-7.035c-0.972,0-1.759,0.788-1.759,1.759 + v9.246c-5.482,3.937-8.794,10.281-8.794,17.136c0,3.284,0.808,6.52,2.278,9.449c-0.313,0.902-0.52,1.855-0.52,2.863 + c0,0.32,0.062,0.625,0.095,0.936l-9.667,9.666c-0.323-0.029-0.65-0.049-0.981-0.049c-5.82,0-10.553,4.733-10.553,10.553 + c0,3.115,1.364,5.91,3.517,7.844v25.574c0,0.971,0.787,1.759,1.759,1.759h10.553c0.973,0,1.759-0.788,1.759-1.759v-25.574 + c2.153-1.932,3.518-4.727,3.518-7.844c0-1.349-0.264-2.635-0.727-3.82l7.762-7.764v45.002c0,0.971,0.786,1.759,1.759,1.759h65.077 + c0.973,0,1.759-0.787,1.759-1.759v-5.275h7.036c0.972,0,1.759-0.789,1.759-1.76v-94.978 + C177.765,109.023,176.979,108.235,176.007,108.235z M102.965,130.539c0.044-0.022,0.092-0.037,0.134-0.063l0.193-0.129l3.835,3.834 + c-4.083,2.049-6.752,6.234-6.752,10.99c0,0.586,0.048,1.169,0.132,1.749c0.029,0.197,0.083,0.387,0.122,0.583 + c0.074,0.376,0.148,0.752,0.257,1.12c0.014,0.051,0.02,0.104,0.035,0.153c-0.103,0.014-0.201,0.049-0.302,0.067 + c-0.204,0.035-0.401,0.084-0.6,0.134c-0.3,0.075-0.592,0.167-0.881,0.274c-0.219,0.082-0.437,0.157-0.646,0.253 + c-0.256,0.118-0.496,0.261-0.739,0.401c-0.371,0.214-0.718,0.454-1.052,0.719c-0.192,0.153-0.393,0.292-0.57,0.461 + c-0.681-1.891-1.031-3.896-1.031-5.914C95.1,139.241,98.084,133.785,102.965,130.539z M106.321,154.306 + c0.119,0.157,0.227,0.318,0.329,0.487c0.063,0.107,0.122,0.217,0.177,0.327c0.08,0.156,0.155,0.313,0.218,0.477 + c0.116,0.304,0.208,0.611,0.268,0.914c0.06,0.317,0.099,0.639,0.099,0.971c0,0.406-0.058,0.803-0.148,1.191 + c-0.028,0.118-0.072,0.227-0.105,0.341c-0.081,0.265-0.176,0.521-0.297,0.77c-0.06,0.122-0.126,0.242-0.197,0.362 + c-0.135,0.229-0.291,0.448-0.459,0.653c-0.081,0.099-0.157,0.202-0.243,0.296c-0.266,0.281-0.556,0.544-0.883,0.767 + c-0.843,0.566-1.855,0.898-2.944,0.898c-0.397,0-0.781-0.052-1.152-0.136c-0.009,0-0.018-0.007-0.026-0.01 + c-0.115-0.027-0.222-0.069-0.333-0.102c-0.132-0.041-0.266-0.073-0.392-0.121c-0.164-0.064-0.318-0.145-0.473-0.225 + c-0.103-0.052-0.202-0.105-0.301-0.163c-0.153-0.091-0.305-0.187-0.447-0.292c-0.075-0.056-0.146-0.119-0.218-0.181 + c-0.259-0.215-0.496-0.45-0.708-0.709c-0.06-0.072-0.123-0.141-0.177-0.216c-0.105-0.143-0.203-0.294-0.294-0.449 + c-0.059-0.098-0.111-0.198-0.164-0.301c-0.079-0.154-0.158-0.31-0.222-0.473c-0.056-0.146-0.097-0.299-0.141-0.45 + c-0.026-0.094-0.063-0.181-0.084-0.276c-0.001-0.007-0.005-0.014-0.007-0.021c-0.084-0.369-0.137-0.754-0.137-1.152 + c0-0.776,0.178-1.507,0.48-2.172c0.005-0.012,0.016-0.021,0.021-0.033c0.193-0.417,0.442-0.793,0.726-1.138 + c0.049-0.058,0.095-0.119,0.146-0.176c0.127-0.142,0.264-0.274,0.404-0.401c0.077-0.069,0.158-0.134,0.241-0.198 + c0.141-0.113,0.28-0.226,0.431-0.322c0.214-0.137,0.438-0.261,0.672-0.364c0.118-0.052,0.241-0.091,0.362-0.137 + c0.178-0.065,0.359-0.124,0.544-0.169c0.107-0.026,0.214-0.052,0.323-0.072c0.303-0.053,0.608-0.093,0.925-0.093 + c0.365,0,0.739,0.044,1.147,0.135h0.001h0.002c0.533,0.118,1.025,0.329,1.481,0.593c0.063,0.037,0.132,0.065,0.191,0.104 + c0.179,0.115,0.343,0.248,0.506,0.383c0.078,0.063,0.156,0.122,0.229,0.19c0.149,0.139,0.287,0.29,0.421,0.443 + C106.186,154.139,106.256,154.22,106.321,154.306z M110.928,157.357c0.58,0.083,1.168,0.125,1.76,0.125 + c4.754,0,8.938-2.667,10.988-6.752l3.863,3.862c-3.22,5.069-8.797,8.166-14.851,8.166c-1.101,0-2.2-0.107-3.289-0.32 + c0.009-0.012,0.014-0.026,0.023-0.038c0.007-0.011,0.012-0.024,0.021-0.035c0.06-0.09,0.105-0.19,0.164-0.281 + c0.091-0.15,0.179-0.3,0.262-0.455c0.102-0.187,0.198-0.377,0.285-0.572c0.049-0.111,0.095-0.224,0.14-0.337 + c0.052-0.131,0.104-0.263,0.15-0.399c0.088-0.255,0.152-0.517,0.216-0.781c0.032-0.13,0.074-0.253,0.099-0.385 + c0.014-0.074,0.028-0.146,0.041-0.22c0.081-0.479,0.13-0.962,0.13-1.453C110.929,157.44,110.929,157.398,110.928,157.357z + M86.306,210.247h-7.035v-21.722c0.545,0.194,1.11,0.346,1.692,0.447c0.035,0.006,0.069,0.011,0.102,0.016 + c0.563,0.094,1.136,0.153,1.724,0.153c0.587,0,1.161-0.06,1.722-0.153c0.035-0.006,0.069-0.009,0.103-0.016 + c0.583-0.102,1.148-0.253,1.693-0.447V210.247z M86.978,184.207c-0.003,0.002-0.007,0.005-0.011,0.006 + c-0.287,0.215-0.589,0.398-0.898,0.562c-0.039,0.021-0.078,0.044-0.118,0.063c-0.301,0.151-0.608,0.28-0.924,0.387 + c-0.055,0.018-0.109,0.033-0.163,0.051c-1.353,0.421-2.8,0.421-4.153,0c-0.054-0.018-0.109-0.033-0.164-0.051 + c-0.315-0.106-0.623-0.234-0.923-0.387c-0.041-0.021-0.079-0.043-0.118-0.063c-0.31-0.163-0.61-0.349-0.898-0.562 + c-0.003-0.001-0.007-0.004-0.011-0.006c-1.715-1.283-2.844-3.314-2.844-5.619c0-3.879,3.155-7.034,7.036-7.034 + c0.542,0,1.064,0.075,1.573,0.192c0.012,0.003,0.024,0.01,0.037,0.014c0.48,0.111,0.943,0.275,1.382,0.486 + c0.004,0.002,0.007,0.004,0.011,0.006c1.319,0.632,2.427,1.678,3.148,3c0.003,0.006,0.009,0.01,0.012,0.015 + c0.539,0.995,0.873,2.115,0.873,3.322C89.823,180.893,88.694,182.922,86.978,184.207z M90.738,171.67 + c-0.875-1.003-1.932-1.84-3.124-2.456l7.077-7.079c0.009,0.016,0.024,0.027,0.034,0.042c0.695,1.095,1.62,2.018,2.714,2.714 + c0.016,0.011,0.027,0.025,0.042,0.033L90.738,171.67z M165.453,210.247h-61.559v-44.148c0.002,0,0.003-0.002,0.005-0.002 + c0.031-0.007,0.06-0.021,0.092-0.027c0.506-0.11,0.996-0.265,1.465-0.456c0.167-0.068,0.32-0.16,0.481-0.239 + c0.101-0.05,0.201-0.097,0.299-0.147c2.11,0.684,4.275,1.05,6.451,1.05c7.784,0,14.913-4.267,18.602-11.138 + c0.367-0.684,0.244-1.526-0.306-2.075l-6.775-6.775c-0.452-0.452-1.111-0.625-1.729-0.447c-0.616,0.178-1.085,0.674-1.228,1.296 + c-0.921,4.021-4.442,6.827-8.563,6.827c-0.939,0-1.852-0.187-2.737-0.479c-0.07-0.135-0.161-0.257-0.239-0.389 + c-0.144-0.248-0.283-0.495-0.45-0.725c-0.113-0.155-0.245-0.296-0.366-0.443c-0.348-0.424-0.731-0.811-1.154-1.163 + c-0.168-0.143-0.331-0.293-0.512-0.422c-0.218-0.157-0.45-0.287-0.683-0.422c-0.218-0.128-0.436-0.251-0.664-0.362 + c-0.236-0.113-0.476-0.209-0.721-0.301c-0.099-0.036-0.188-0.088-0.289-0.119c-0.05-0.095-0.102-0.188-0.147-0.287 + c-0.063-0.135-0.123-0.272-0.18-0.41c-0.088-0.216-0.165-0.438-0.235-0.659c-0.042-0.134-0.086-0.268-0.122-0.403 + c-0.065-0.244-0.113-0.494-0.155-0.744c-0.02-0.118-0.048-0.233-0.063-0.352c-0.049-0.371-0.078-0.742-0.078-1.115 + c0-4.121,2.807-7.644,6.825-8.563c0.622-0.143,1.12-0.614,1.296-1.228c0.176-0.614,0.007-1.277-0.446-1.729l-6.775-6.775 + c-0.132-0.132-0.283-0.237-0.443-0.318c-0.053-0.026-0.11-0.037-0.166-0.06c-0.097-0.037-0.19-0.084-0.292-0.104v-5.846h7.036 + h54.523v89.7H165.453z M174.247,203.212h-5.276v-84.424c0-0.971-0.786-1.759-1.76-1.759h-54.523v-5.277h61.559V203.212 + L174.247,203.212z"/> + <path fill="#43515F" d="M144.347,196.177c0-0.97-0.786-1.759-1.759-1.759h-5.277v-7.035c0-0.971-0.785-1.759-1.759-1.759h-5.276 + v-12.312c0-0.972-0.786-1.76-1.759-1.76h-7.035c-0.973,0-1.759,0.788-1.759,1.76v8.793h-5.276c-0.973,0-1.759,0.788-1.759,1.76 + v17.589h-3.518v3.517h38.695v-3.517h-3.518V196.177L144.347,196.177z M116.206,185.624h3.518v15.829h-3.518V185.624z + M123.241,183.864v-8.794h3.518v12.312v14.07h-3.518V183.864z M130.276,189.141h3.519v7.036v5.276h-3.519V189.141z + M137.312,201.453v-3.518h3.519v3.518H137.312z"/> + <path fill="#43515F" d="M161.844,169.476c0.008-0.064,0.021-0.128,0.029-0.193c0.042-0.411,0.062-0.829,0.062-1.247 + c0-6.789-5.523-12.312-12.312-12.312c-6.787,0-12.312,5.523-12.312,12.312c0,0.418,0.021,0.836,0.063,1.247 + c0.007,0.065,0.021,0.129,0.028,0.193c0.04,0.349,0.089,0.693,0.158,1.034c0.003,0.016,0.009,0.031,0.012,0.05 + c0.481,2.325,1.625,4.464,3.346,6.182c0.009,0.009,0.02,0.011,0.029,0.02c2.227,2.215,5.294,3.587,8.675,3.587 + s6.448-1.372,8.675-3.587c0.01-0.009,0.021-0.011,0.03-0.02c1.719-1.719,2.862-3.856,3.346-6.182 + c0.003-0.018,0.009-0.034,0.011-0.05C161.754,170.169,161.804,169.824,161.844,169.476z M158.256,169.635 + c-0.024,0.137-0.06,0.271-0.09,0.406c-0.097,0.414-0.221,0.817-0.376,1.206c-0.038,0.1-0.072,0.202-0.115,0.301 + c-0.204,0.465-0.446,0.911-0.724,1.329l-2.565-2.566c0.017-0.037,0.023-0.08,0.042-0.119c0.094-0.209,0.159-0.43,0.227-0.654 + c0.038-0.13,0.092-0.253,0.119-0.387c0.081-0.36,0.127-0.731,0.127-1.115c0-0.332-0.039-0.654-0.099-0.969 + c-0.018-0.099-0.046-0.191-0.071-0.288c-0.052-0.216-0.116-0.429-0.196-0.634c-0.038-0.1-0.077-0.197-0.123-0.293 + c-0.095-0.206-0.204-0.404-0.323-0.595c-0.046-0.073-0.084-0.149-0.134-0.22c-0.177-0.254-0.37-0.49-0.586-0.709 + c-0.036-0.039-0.081-0.069-0.12-0.105c-0.184-0.176-0.38-0.34-0.589-0.488c-0.083-0.057-0.169-0.108-0.255-0.163 + c-0.182-0.114-0.369-0.216-0.566-0.307c-0.095-0.045-0.188-0.089-0.287-0.129c-0.058-0.023-0.111-0.055-0.17-0.075v-3.641 + c0.001,0,0.003,0,0.005,0c0.032,0.007,0.06,0.021,0.092,0.028c0.507,0.109,0.995,0.264,1.465,0.456 + c0.168,0.068,0.32,0.16,0.482,0.239c0.321,0.156,0.64,0.318,0.939,0.51c0.174,0.113,0.334,0.239,0.499,0.365 + c0.262,0.197,0.517,0.401,0.757,0.624c0.155,0.146,0.302,0.301,0.448,0.459c0.218,0.236,0.422,0.484,0.612,0.742 + c0.126,0.173,0.251,0.347,0.366,0.53c0.177,0.278,0.33,0.571,0.473,0.868c0.09,0.187,0.186,0.369,0.263,0.563 + c0.134,0.338,0.235,0.689,0.325,1.047c0.046,0.173,0.105,0.341,0.141,0.521c0.105,0.535,0.17,1.094,0.17,1.665 + C158.418,168.582,158.353,169.113,158.256,169.635z M148.381,169.279L148.381,169.279c-0.166-0.164-0.291-0.355-0.379-0.562 + c-0.088-0.21-0.137-0.44-0.137-0.682c0-0.969,0.788-1.758,1.759-1.758s1.759,0.789,1.759,1.758c0,0.241-0.049,0.472-0.137,0.682 + c-0.088,0.207-0.213,0.398-0.378,0.562l0,0c-0.318,0.318-0.759,0.516-1.243,0.516C149.14,169.795,148.698,169.597,148.381,169.279z + M140.998,166.364c0.035-0.179,0.097-0.347,0.141-0.521c0.092-0.355,0.191-0.707,0.325-1.045c0.077-0.193,0.175-0.377,0.265-0.564 + c0.144-0.297,0.297-0.592,0.47-0.869c0.114-0.18,0.239-0.355,0.368-0.529c0.192-0.259,0.395-0.506,0.612-0.742 + c0.146-0.156,0.292-0.312,0.448-0.459c0.237-0.223,0.494-0.427,0.757-0.623c0.165-0.123,0.325-0.251,0.499-0.364 + c0.299-0.192,0.615-0.354,0.938-0.51c0.162-0.079,0.315-0.171,0.482-0.24c0.469-0.193,0.958-0.346,1.465-0.456 + c0.031-0.007,0.06-0.021,0.092-0.028c0.001,0,0.003,0,0.005,0v3.641c-0.06,0.021-0.112,0.053-0.171,0.076 + c-0.098,0.038-0.191,0.082-0.286,0.126c-0.198,0.093-0.386,0.193-0.566,0.308c-0.087,0.055-0.173,0.105-0.255,0.164 + c-0.209,0.147-0.405,0.311-0.59,0.487c-0.038,0.036-0.082,0.067-0.119,0.105c-0.217,0.218-0.409,0.456-0.586,0.709 + c-0.05,0.071-0.088,0.146-0.134,0.22c-0.12,0.191-0.229,0.388-0.323,0.596c-0.044,0.097-0.084,0.193-0.121,0.292 + c-0.079,0.206-0.145,0.417-0.198,0.636c-0.022,0.096-0.051,0.189-0.069,0.288c-0.061,0.317-0.099,0.64-0.099,0.972 + c0,0.385,0.046,0.756,0.123,1.115c0.028,0.134,0.082,0.259,0.121,0.389c0.067,0.222,0.134,0.443,0.227,0.652 + c0.018,0.039,0.023,0.082,0.043,0.119l-2.567,2.566c-0.277-0.418-0.519-0.863-0.722-1.329c-0.043-0.099-0.075-0.2-0.116-0.301 + c-0.153-0.389-0.278-0.793-0.377-1.206c-0.031-0.136-0.064-0.27-0.089-0.406c-0.095-0.521-0.16-1.053-0.16-1.6 + C140.83,167.464,140.893,166.906,140.998,166.364z M144.781,175.364l2.58-2.582c0.689,0.329,1.449,0.53,2.262,0.53 + s1.572-0.2,2.261-0.53l2.58,2.582c-1.39,0.923-3.052,1.466-4.841,1.466C147.835,176.83,146.173,176.286,144.781,175.364z"/> + <path fill="#43515F" d="M156.658,124.064c-2.908,0-5.276,2.367-5.276,5.276c0,0.219,0.039,0.426,0.065,0.639l-8.724,4.362 + c-0.949-0.913-2.233-1.483-3.653-1.483c-1.921,0-3.588,1.043-4.512,2.581l-7.875-1.576c-0.369-2.55-2.549-4.521-5.201-4.521 + c-2.909,0-5.276,2.367-5.276,5.276s2.367,5.277,5.276,5.277c1.921,0,3.588-1.043,4.511-2.581l7.876,1.576 + c0.369,2.55,2.549,4.521,5.201,4.521c2.908,0,5.276-2.367,5.276-5.276c0-0.218-0.038-0.427-0.064-0.64l8.724-4.362 + c0.949,0.914,2.233,1.484,3.652,1.484c2.909,0,5.277-2.368,5.277-5.277S159.568,124.064,156.658,124.064z M121.482,136.376 + c-0.971,0-1.759-0.79-1.759-1.759c0-0.969,0.788-1.759,1.759-1.759s1.759,0.79,1.759,1.759S122.453,136.376,121.482,136.376z + M139.071,139.894c-0.972,0-1.76-0.79-1.76-1.759c0-0.969,0.788-1.759,1.76-1.759s1.759,0.79,1.759,1.759 + C140.83,139.104,140.042,139.894,139.071,139.894z M156.658,131.1c-0.971,0-1.758-0.788-1.758-1.759l0,0 + c0-0.969,0.787-1.759,1.758-1.759s1.76,0.79,1.76,1.759S157.63,131.1,156.658,131.1z"/> +</g> +</svg> diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 45df65ebe55cf70e2f69aa1e1c38ced1ac75dbb4..3c4f858c5584fd594f976b9c0f61b7c410ea6e49 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -28,6 +28,7 @@ describe('MetadataFieldFormComponent', () => { const registryServiceStub = { getActiveMetadataField: () => observableOf(undefined), createOrUpdateMetadataField: (field: MetadataField) => observableOf(field), + cancelEditMetadataField: () => {}, cancelEditMetadataSchema: () => {}, }; const formBuilderServiceStub = { @@ -62,6 +63,11 @@ describe('MetadataFieldFormComponent', () => { registryService = s; })); + afterEach(() => { + component = null; + registryService = null + }) + describe('when submitting the form', () => { const element = 'fakeElement'; const qualifier = 'fakeQualifier'; diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index f148627297ad5765c7323e6eae207cfd23d1cc4a..3ad1bd4272dc1eabc6190b6ce664fbda083a247f 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -138,13 +138,18 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { parentID: 'new', active: false, visible: true, + // model: { + // type: MenuItemType.ONCLICK, + // text: 'menu.section.new_item', + // function: () => { + // this.modalService.open(CreateItemParentSelectorComponent); + // } + // } as OnClickMenuItemModel, model: { - type: MenuItemType.ONCLICK, + type: MenuItemType.LINK, text: 'menu.section.new_item', - function: () => { - this.modalService.open(CreateItemParentSelectorComponent); - } - } as OnClickMenuItemModel, + link: '/submit' + } as LinkMenuItemModel, }, { id: 'new_item_version', @@ -154,7 +159,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.new_item_version', - link: '#' + link: '' } as LinkMenuItemModel, }, @@ -230,7 +235,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.import_metadata', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -241,7 +246,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.import_batch', - link: '#' + link: '' } as LinkMenuItemModel, }, /* Export */ @@ -264,7 +269,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_community', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -275,7 +280,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_collection', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -286,7 +291,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_item', - link: '#' + link: '' } as LinkMenuItemModel, }, { id: 'export_metadata', @@ -296,7 +301,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.export_metadata', - link: '#' + link: '' } as LinkMenuItemModel, }, @@ -320,7 +325,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_people', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -331,7 +336,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_groups', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -342,7 +347,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_authorizations', - link: '#' + link: '' } as LinkMenuItemModel, }, @@ -377,7 +382,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.find_withdrawn_items', - link: '#' + link: '' } as LinkMenuItemModel, }, { @@ -388,7 +393,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.find_private_items', - link: '/admin/items' + link: '' } as LinkMenuItemModel, }, @@ -435,7 +440,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.curation_task', - link: '/curation' + link: '' } as LinkMenuItemModel, icon: 'filter', index: 7 @@ -449,7 +454,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.statistics_task', - link: '#' + link: '' } as LinkMenuItemModel, icon: 'chart-bar', index: 8 @@ -463,7 +468,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.control_panel', - link: '#' + link: '' } as LinkMenuItemModel, icon: 'cogs', index: 9 diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts index 4921be77e227ac808a7a8655e18dfbdda05a8fff..112560de16e3c8a9b89e0dea4221560f0eb6903a 100644 --- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -18,8 +18,8 @@ import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorat templateUrl: './expandable-admin-sidebar-section.component.html', styleUrls: ['./expandable-admin-sidebar-section.component.scss'], animations: [rotate, slide, bgColor] - }) + @rendersSectionForMenu(MenuID.ADMIN, true) export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit { /** diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 6265b223d887dcf4244e9734b4acc6ef756fa3d6..91239de17c64de4fa8a0b4351b0503c557645f64 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -1,58 +1,62 @@ <div class="container"> - <div class="collection-page" - *ngVar="(collectionRD$ | async) as collectionRD"> - <div *ngIf="collectionRD?.hasSucceeded" @fadeInOut> - <div *ngIf="collectionRD?.payload as collection"> - <!-- Collection Name --> - <ds-comcol-page-header - [name]="collection.name"> - </ds-comcol-page-header> - <!-- Browse-By Links --> - <ds-comcol-page-browse-by [id]="collection.id"></ds-comcol-page-browse-by> - <!-- Collection logo --> - <ds-comcol-page-logo *ngIf="logoRD$" - [logo]="(logoRD$ | async)?.payload" - [alternateText]="'Collection Logo'"> - </ds-comcol-page-logo> - <!-- Introductionary text --> - <ds-comcol-page-content - [content]="collection.introductoryText" - [hasInnerHtml]="true"> - </ds-comcol-page-content> - <!-- News --> - <ds-comcol-page-content - [content]="collection.sidebarText" - [hasInnerHtml]="true" - [title]="'community.page.news'"> - </ds-comcol-page-content> - <!-- Copyright --> - <ds-comcol-page-content - [content]="collection.copyrightText" - [hasInnerHtml]="true"> - </ds-comcol-page-content> - <!-- Licence --> - <ds-comcol-page-content - [content]="collection.dcLicense" - [title]="'collection.page.license'"> - </ds-comcol-page-content> - </div> + <div class="collection-page" + *ngVar="(collectionRD$ | async) as collectionRD"> + <div *ngIf="collectionRD?.hasSucceeded" @fadeInOut> + <div *ngIf="collectionRD?.payload as collection"> + <!-- Collection Name --> + <ds-comcol-page-header + [name]="collection.name"> + </ds-comcol-page-header> + <!-- Browse-By Links --> + <ds-comcol-page-browse-by [id]="collection.id"></ds-comcol-page-browse-by> + <!-- Collection logo --> + <ds-comcol-page-logo *ngIf="logoRD$" + [logo]="(logoRD$ | async)?.payload" + [alternateText]="'Collection Logo'"> + </ds-comcol-page-logo> + <!-- Introductionary text --> + <ds-comcol-page-content + [content]="collection.introductoryText" + [hasInnerHtml]="true"> + </ds-comcol-page-content> + <!-- News --> + <ds-comcol-page-content + [content]="collection.sidebarText" + [hasInnerHtml]="true" + [title]="'community.page.news'"> + </ds-comcol-page-content> + <!-- Copyright --> + <ds-comcol-page-content + [content]="collection.copyrightText" + [hasInnerHtml]="true"> + </ds-comcol-page-content> + <!-- Licence --> + <ds-comcol-page-content + [content]="collection.dcLicense" + [title]="'collection.page.license'"> + </ds-comcol-page-content> + </div> + <br> + <ng-container *ngVar="(itemRD$ | async) as itemRD"> + <div *ngIf="itemRD?.hasSucceeded" @fadeIn> + <h2>{{'collection.page.browse.recent.head' | translate}}</h2> + <ds-viewable-collection + [config]="paginationConfig" + [sortConfig]="sortConfig" + [objects]="itemRD" + [hideGear]="true" + (paginationChange)="onPaginationChange($event)"> + </ds-viewable-collection> + </div> + <ds-error *ngIf="itemRD?.hasFailed" + message="{{'error.recent-submissions' | translate}}"></ds-error> + <ds-loading *ngIf="!itemRD || itemRD.isLoading" + message="{{'loading.recent-submissions' | translate}}"></ds-loading> + </ng-container> + </div> + <ds-error *ngIf="collectionRD?.hasFailed" + message="{{'error.collection' | translate}}"></ds-error> + <ds-loading *ngIf="collectionRD?.isLoading" + message="{{'loading.collection' | translate}}"></ds-loading> </div> - <ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error> - <ds-loading *ngIf="collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-loading> - <br> - <ng-container *ngVar="(itemRD$ | async) as itemRD"> - <div *ngIf="itemRD?.hasSucceeded" @fadeIn> - <h2>{{'collection.page.browse.recent.head' | translate}}</h2> - <ds-viewable-collection - [config]="paginationConfig" - [sortConfig]="sortConfig" - [objects]="itemRD" - [hideGear]="true" - (paginationChange)="onPaginationChange($event)"> - </ds-viewable-collection> - </div> - <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error> - <ds-loading *ngIf="!itemRD || itemRD.isLoading" message="{{'loading.recent-submissions' | translate}}"></ds-loading> - </ng-container> - </div> </div> diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 7c4f2b92ac880fface1cb1552f387f2d8999cb56..41afbf2115b6211bc9fe1002b45f3832ab867247 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,6 +1,9 @@ -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { Observable, Subscription } from 'rxjs'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject, of as observableOf, Observable, Subject } from 'rxjs'; +import { filter, flatMap, map, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchService } from '../+search-page/search-service/search.service'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { PaginatedList } from '../core/data/paginated-list'; @@ -10,16 +13,17 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { Bitstream } from '../core/shared/bitstream.model'; import { Collection } from '../core/shared/collection.model'; +import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; import { Item } from '../core/shared/item.model'; +import { + getSucceededRemoteData, + redirectToPageNotFoundOn404, + toDSpaceObjectListRD +} from '../core/shared/operators'; import { fadeIn, fadeInOut } from '../shared/animations/fade'; -import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../shared/empty.util'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { filter, flatMap, map, tap } from 'rxjs/operators'; -import { SearchService } from '../+search-page/search-service/search.service'; -import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; -import { toDSpaceObjectListRD } from '../core/shared/operators'; -import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; @Component({ selector: 'ds-collection-page', @@ -31,20 +35,23 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; fadeInOut ] }) -export class CollectionPageComponent implements OnInit, OnDestroy { +export class CollectionPageComponent implements OnInit { collectionRD$: Observable<RemoteData<Collection>>; itemRD$: Observable<RemoteData<PaginatedList<Item>>>; logoRD$: Observable<RemoteData<Bitstream>>; paginationConfig: PaginationComponentOptions; sortConfig: SortOptions; - private subs: Subscription[] = []; - private collectionId: string; + private paginationChanges$: Subject<{ + paginationConfig: PaginationComponentOptions, + sortConfig: SortOptions + }>; constructor( private collectionDataService: CollectionDataService, private searchService: SearchService, private metadata: MetadataService, - private route: ActivatedRoute + private route: ActivatedRoute, + private router: Router ) { this.paginationConfig = new PaginationComponentOptions(); this.paginationConfig.id = 'collection-page-pagination'; @@ -55,43 +62,43 @@ export class CollectionPageComponent implements OnInit, OnDestroy { ngOnInit(): void { this.collectionRD$ = this.route.data.pipe( - map((data) => data.collection), - tap((data) => this.collectionId = data.payload.id) + map((data) => data.collection as RemoteData<Collection>), + redirectToPageNotFoundOn404(this.router), + take(1) ); this.logoRD$ = this.collectionRD$.pipe( map((rd: RemoteData<Collection>) => rd.payload), filter((collection: Collection) => hasValue(collection)), flatMap((collection: Collection) => collection.logo) ); - this.subs.push( - this.route.queryParams.subscribe((params) => { - this.metadata.processRemoteData(this.collectionRD$); - const page = +params.page || this.paginationConfig.currentPage; - const pageSize = +params.pageSize || this.paginationConfig.pageSize; - const pagination = Object.assign({}, - this.paginationConfig, - { currentPage: page, pageSize: pageSize } - ); - this.updatePage({ - pagination: pagination, - sort: this.sortConfig - }); - })); - } + this.paginationChanges$ = new BehaviorSubject({ + paginationConfig: this.paginationConfig, + sortConfig: this.sortConfig + }); - updatePage(searchOptions) { - this.itemRD$ = this.searchService.search( - new PaginatedSearchOptions({ - scope: this.collectionId, - pagination: searchOptions.pagination, - sort: searchOptions.sort, - dsoType: DSpaceObjectType.ITEM - })).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>; - } + this.itemRD$ = this.paginationChanges$.pipe( + switchMap((dto) => this.collectionRD$.pipe( + getSucceededRemoteData(), + map((rd) => rd.payload.id), + switchMap((id: string) => { + return this.searchService.search( + new PaginatedSearchOptions({ + scope: id, + pagination: dto.paginationConfig, + sort: dto.sortConfig, + dsoType: DSpaceObjectType.ITEM + })).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>> + }), + startWith(undefined) // Make sure switching pages shows loading component + ) + ) + ); - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + this.route.queryParams.pipe(take(1)).subscribe((params) => { + this.metadata.processRemoteData(this.collectionRD$); + this.onPaginationChange(params); + }) } isNotEmpty(object: any) { @@ -99,15 +106,14 @@ export class CollectionPageComponent implements OnInit, OnDestroy { } onPaginationChange(event) { - this.updatePage({ - pagination: { - currentPage: event.page, - pageSize: event.pageSize - }, - sort: { - field: event.sortField, - direction: event.sortDirection - } - }) + this.paginationConfig.currentPage = +event.page || this.paginationConfig.currentPage; + this.paginationConfig.pageSize = +event.pageSize || this.paginationConfig.pageSize; + this.sortConfig.direction = event.sortDirection || this.sortConfig.direction; + this.sortConfig.field = event.sortField || this.sortConfig.field; + + this.paginationChanges$.next({ + paginationConfig: this.paginationConfig, + sortConfig: this.sortConfig + }); } } diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts index f0e4138d2dfd180515e8182ec4f7ac6094b4a9a0..bdeffa34f38bf23cbbc0d232559ed9148fbb1738 100644 --- a/src/app/+collection-page/collection-page.module.ts +++ b/src/app/+collection-page/collection-page.module.ts @@ -7,9 +7,9 @@ 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'; +import { SearchService } from '../+search-page/search-service/search.service'; @NgModule({ imports: [ @@ -23,6 +23,9 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c EditCollectionPageComponent, DeleteCollectionPageComponent, CollectionFormComponent + ], + providers: [ + SearchService ] }) export class CollectionPageModule { diff --git a/src/app/+collection-page/collection-page.resolver.ts b/src/app/+collection-page/collection-page.resolver.ts index d4835e2e14359e3054158dc37e703aa3e815e3f9..8c6e3ad8a65d861304a399690488589ddc49989b 100644 --- a/src/app/+collection-page/collection-page.resolver.ts +++ b/src/app/+collection-page/collection-page.resolver.ts @@ -4,7 +4,8 @@ import { Collection } from '../core/shared/collection.model'; import { Observable } from 'rxjs'; import { CollectionDataService } from '../core/data/collection-data.service'; import { RemoteData } from '../core/data/remote-data'; -import { getSucceededRemoteData } from '../core/shared/operators'; +import { find } from 'rxjs/operators'; +import { hasValue } from '../shared/empty.util'; /** * This class represents a resolver that requests a specific collection before the route is activated @@ -18,11 +19,12 @@ export class CollectionPageResolver implements Resolve<RemoteData<Collection>> { * Method for resolving a collection based on the parameters in the current route * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<<RemoteData<Collection>> Emits the found collection based on the parameters in the current route + * @returns Observable<<RemoteData<Collection>> Emits the found collection based on the parameters in the current route, + * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Collection>> { return this.collectionService.findById(route.params.id).pipe( - getSucceededRemoteData() + find((RD) => hasValue(RD.error) || RD.hasSucceeded), ); } } 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 index a3978a5e43ef49f41bd9ef7881277bf41f4b0c6b..ba70bd26c6ba5566dafa5114805d0dc7c8752c50 100644 --- 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 @@ -1,7 +1,6 @@ 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'; diff --git a/src/app/+community-page/community-page.component.ts b/src/app/+community-page/community-page.component.ts index 2035faf988b32ac7f131a493c49da427865709c9..f337d70250aca257c0693959b2e8037e1eec8991 100644 --- a/src/app/+community-page/community-page.component.ts +++ b/src/app/+community-page/community-page.component.ts @@ -1,6 +1,6 @@ import { mergeMap, filter, map } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Subscription, Observable } from 'rxjs'; import { CommunityDataService } from '../core/data/community-data.service'; @@ -13,6 +13,7 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; +import { redirectToPageNotFoundOn404 } from '../core/shared/operators'; @Component({ selector: 'ds-community-page', @@ -37,13 +38,17 @@ export class CommunityPageComponent implements OnInit { constructor( private communityDataService: CommunityDataService, private metadata: MetadataService, - private route: ActivatedRoute + private route: ActivatedRoute, + private router: Router ) { } ngOnInit(): void { - this.communityRD$ = this.route.data.pipe(map((data) => data.community)); + this.communityRD$ = this.route.data.pipe( + map((data) => data.community as RemoteData<Community>), + redirectToPageNotFoundOn404(this.router) + ); this.logoRD$ = this.communityRD$.pipe( map((rd: RemoteData<Community>) => rd.payload), filter((community: Community) => hasValue(community)), diff --git a/src/app/+community-page/community-page.resolver.ts b/src/app/+community-page/community-page.resolver.ts index a32fe78bc5de6c90095e2756f895f30ab6548e5b..ffa66fa1235b68a28e3fcedd9de2d4d31e250d6c 100644 --- a/src/app/+community-page/community-page.resolver.ts +++ b/src/app/+community-page/community-page.resolver.ts @@ -2,9 +2,10 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; import { RemoteData } from '../core/data/remote-data'; -import { getSucceededRemoteData } from '../core/shared/operators'; import { Community } from '../core/shared/community.model'; import { CommunityDataService } from '../core/data/community-data.service'; +import { find } from 'rxjs/operators'; +import { hasValue } from '../shared/empty.util'; /** * This class represents a resolver that requests a specific community before the route is activated @@ -18,11 +19,12 @@ export class CommunityPageResolver implements Resolve<RemoteData<Community>> { * Method for resolving a community based on the parameters in the current route * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<<RemoteData<Community>> Emits the found community based on the parameters in the current route + * @returns Observable<<RemoteData<Community>> Emits the found community based on the parameters in the current route, + * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Community>> { return this.communityService.findById(route.params.id).pipe( - getSucceededRemoteData() + find((RD) => hasValue(RD.error) || RD.hasSucceeded) ); } } 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 47ceaac90fd3d5fe022a8a793af68112fc885a4a..28e10c5804abd03eb8102e5ecc727c9967227393 100644 --- a/src/app/+home-page/home-news/home-news.component.html +++ b/src/app/+home-page/home-news/home-news.component.html @@ -3,7 +3,7 @@ <div class="d-flex flex-wrap"> <img class="mr-4 dspace-logo" src="assets/images/dspace-logo.svg" alt="" /> <div> - <h1 class="display-3">Welcome to the DSpace 7 Preview Release</h1> + <h1 class="display-3">Welcome to the DSpace 7 Preview</h1> <p class="lead">DSpace is the world leading open source repository platform that enables organisations to:</p> </div> </div> diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html index 6a3e20ca9dade4c04d8636063cb6507113720556..39ba4790336675753fef8ca092d85242b339da5f 100644 --- a/src/app/+home-page/home-page.component.html +++ b/src/app/+home-page/home-page.component.html @@ -1,5 +1,5 @@ <ds-home-news></ds-home-news> <div class="container"> - <ds-search-form></ds-search-form> + <ds-search-form [inPlaceSearch]="false"></ds-search-form> <ds-top-level-community-list></ds-top-level-community-list> </div> 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 4ea47f08e73a48a1b05e1ab03f577374e754936f..eafc04ae0b491da4dbc070461974dfb2f80e63c7 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,6 +1,6 @@ import { fadeIn, fadeInOut } from '../../shared/animations/fade'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Params, Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { Observable } from 'rxjs'; diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html index bbe6d8d95b114d7c67fa70920e023f1fa866350a..c791cec600a8e4748929706693815f8b6c764852 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html @@ -1,4 +1,4 @@ -<div class="simple-view-element" [class.d-none]="content.textContent.trim().length === 0"> +<div class="simple-view-element" [class.d-none]="content.textContent.trim().length === 0 && hasNoValue(content.querySelector('img'))"> <h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5> <div #content class="simple-view-element-body"> <ng-content></ng-content> diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts index cce54edf64e9944e5f760a3718de24b26296b878..d7e1b80c766064edc44366b6163d8c0a21395161 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts @@ -1,18 +1,41 @@ -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Component, DebugElement } from '@angular/core'; +import { Component } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component'; +/* tslint:disable:max-classes-per-file */ @Component({ - selector: 'ds-component-with-content', + selector: 'ds-component-without-content', template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + - ' <div class="my-content">\n' + + '</ds-metadata-field-wrapper>' +}) +class NoContentComponent {} + +@Component({ + selector: 'ds-component-with-empty-spans', + template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + + ' <span></span>\n' + ' <span></span>\n' + - ' </div>\n' + '</ds-metadata-field-wrapper>' }) -class ContentComponent {} +class SpanContentComponent {} + +@Component({ + selector: 'ds-component-with-text', + template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + + ' <span>The quick brown fox jumps over the lazy dog</span>\n' + + '</ds-metadata-field-wrapper>' +}) +class TextContentComponent {} + +@Component({ + selector: 'ds-component-with-image', + template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + + ' <img src="https://some/image.png" alt="an alt text">\n' + + '</ds-metadata-field-wrapper>' +}) +class ImgContentComponent {} +/* tslint:enable:max-classes-per-file */ describe('MetadataFieldWrapperComponent', () => { let component: MetadataFieldWrapperComponent; @@ -20,7 +43,7 @@ describe('MetadataFieldWrapperComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [MetadataFieldWrapperComponent, ContentComponent] + declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent] }).compileComponents(); })); @@ -30,23 +53,21 @@ describe('MetadataFieldWrapperComponent', () => { }); const wrapperSelector = '.simple-view-element'; - const labelSelector = '.simple-view-element-header'; - const contentSelector = '.my-content'; it('should create', () => { expect(component).toBeDefined(); }); it('should not show the component when there is no content', () => { - component.label = 'test label'; - fixture.detectChanges(); - const parentNative = fixture.nativeElement; + const parentFixture = TestBed.createComponent(NoContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; const nativeWrapper = parentNative.querySelector(wrapperSelector); expect(nativeWrapper.classList.contains('d-none')).toBe(true); }); - it('should not show the component when there is DOM content but no text', () => { - const parentFixture = TestBed.createComponent(ContentComponent); + it('should not show the component when there is DOM content but not text or an image', () => { + const parentFixture = TestBed.createComponent(SpanContentComponent); parentFixture.detectChanges(); const parentNative = parentFixture.nativeElement; const nativeWrapper = parentNative.querySelector(wrapperSelector); @@ -54,11 +75,18 @@ describe('MetadataFieldWrapperComponent', () => { }); it('should show the component when there is text content', () => { - const parentFixture = TestBed.createComponent(ContentComponent); + const parentFixture = TestBed.createComponent(TextContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + parentFixture.detectChanges(); + expect(nativeWrapper.classList.contains('d-none')).toBe(false); + }); + + it('should show the component when there is img content', () => { + const parentFixture = TestBed.createComponent(ImgContentComponent); parentFixture.detectChanges(); const parentNative = parentFixture.nativeElement; - const nativeContent = parentNative.querySelector(contentSelector); - nativeContent.textContent = 'lorem ipsum'; const nativeWrapper = parentNative.querySelector(wrapperSelector); parentFixture.detectChanges(); expect(nativeWrapper.classList.contains('d-none')).toBe(false); diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts index 8c80384732c28816c8daa46f3e4f48f61c2fc328..8af108cceb80a4eb507c21447c61528d49fe0900 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts @@ -1,4 +1,5 @@ import { Component, Input } from '@angular/core'; +import { hasNoValue } from '../../../shared/empty.util'; /** * This component renders any content inside this wrapper. @@ -11,6 +12,15 @@ import { Component, Input } from '@angular/core'; }) export class MetadataFieldWrapperComponent { + /** + * The label (title) for the content + */ @Input() label: string; + /** + * Make hasNoValue() available in the template + */ + hasNoValue(o: any): boolean { + return hasNoValue(o); + } } diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b32ece3c34e98178e76d4d6276cfdc40540b839 --- /dev/null +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts @@ -0,0 +1,97 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader'; +import { By } from '@angular/platform-browser'; +import { MetadataUriValuesComponent } from './metadata-uri-values.component'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { MetadataValue } from '../../../core/shared/metadata.models'; + +let comp: MetadataUriValuesComponent; +let fixture: ComponentFixture<MetadataUriValuesComponent>; + +const mockMetadata = [ + { + language: 'en_US', + value: 'http://fakelink.org' + }, + { + language: 'en_US', + value: 'http://another.fakelink.org' + } +] as MetadataValue[]; +const mockSeperator = '<br/>'; +const mockLabel = 'fake.message'; +const mockLinkText = 'fake link text'; + +describe('MetadataUriValuesComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [MetadataUriValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataUriValuesComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(MetadataUriValuesComponent); + comp = fixture.componentInstance; + comp.mdValues = mockMetadata; + comp.separator = mockSeperator; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display all metadata values', () => { + const innerHTML = fixture.nativeElement.innerHTML; + for (const metadatum of mockMetadata) { + expect(innerHTML).toContain(metadatum.value); + } + }); + + it('should contain the correct hrefs', () => { + const links = fixture.debugElement.queryAll(By.css('a')); + for (const metadatum of mockMetadata) { + expect(containsHref(links, metadatum.value)).toBeTruthy(); + } + }); + + it('should contain separators equal to the amount of metadata values minus one', () => { + const separators = fixture.debugElement.queryAll(By.css('a span')); + expect(separators.length).toBe(mockMetadata.length - 1); + }); + + describe('when linktext is defined', () => { + + beforeEach(() => { + comp.linktext = mockLinkText; + fixture.detectChanges(); + }); + + it('should replace the metadata value with the linktext', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.textContent).toContain(mockLinkText); + }); + + }); + +}); + +function containsHref(links: DebugElement[], href: string): boolean { + for (const link of links) { + const hrefAtt = link.properties.href; + if (isNotEmpty(hrefAtt)) { + if (hrefAtt === href) { + return true; + } + } + } + return false; +} diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 67684d44af13c8e8f905d4ef329c023d86fc8264..e070eccf2d5450af924ea673795c2b5fabf7b090 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 @@ -17,11 +17,24 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; }) export class MetadataUriValuesComponent extends MetadataValuesComponent { + /** + * Optional text to replace the links with + * If undefined, the metadata value (uri) is displayed + */ @Input() linktext: any; + /** + * The metadata values to display + */ @Input() mdValues: MetadataValue[]; + /** + * The seperator used to split the metadata values (can contain HTML) + */ @Input() separator: string; + /** + * The label for this iteration of metadata values + */ @Input() label: string; } diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cad2edb98a9baa06b7e870abca61a826b84c157f --- /dev/null +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts @@ -0,0 +1,65 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader'; +import { MetadataValuesComponent } from './metadata-values.component'; +import { By } from '@angular/platform-browser'; +import { MetadataValue } from '../../../core/shared/metadata.models'; + +let comp: MetadataValuesComponent; +let fixture: ComponentFixture<MetadataValuesComponent>; + +const mockMetadata = [ + { + language: 'en_US', + value: '1234' + }, + { + language: 'en_US', + value: 'a publisher' + }, + { + language: 'en_US', + value: 'desc' + }] as MetadataValue[]; +const mockSeperator = '<br/>'; +const mockLabel = 'fake.message'; + +describe('MetadataValuesComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataValuesComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(MetadataValuesComponent); + comp = fixture.componentInstance; + comp.mdValues = mockMetadata; + comp.separator = mockSeperator; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display all metadata values', () => { + const innerHTML = fixture.nativeElement.innerHTML; + for (const metadatum of mockMetadata) { + expect(innerHTML).toContain(metadatum.value); + } + }); + + it('should contain separators equal to the amount of metadata values minus one', () => { + const separators = fixture.debugElement.queryAll(By.css('span>span')); + expect(separators.length).toBe(mockMetadata.length - 1); + }); + +}); diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts index abcd90848d1187315e341761ff3257d4c0c3d751..142b08b360da340065dafa5cc6031fb39f9124de 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 @@ -12,10 +12,19 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; }) export class MetadataValuesComponent { + /** + * The metadata values to display + */ @Input() mdValues: MetadataValue[]; + /** + * The seperator used to split the metadata values (can contain HTML) + */ @Input() separator: string; + /** + * The label for this iteration of metadata values + */ @Input() label: string; } diff --git a/src/app/+item-page/full/full-item-page.component.spec.ts b/src/app/+item-page/full/full-item-page.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..15dd0019642d67ae7029499f775ca8b45dbe240f --- /dev/null +++ b/src/app/+item-page/full/full-item-page.component.spec.ts @@ -0,0 +1,78 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { TruncatePipe } from '../../shared/utils/truncate.pipe'; +import { FullItemPageComponent } from './full-item-page.component'; +import { MetadataService } from '../../core/metadata/metadata.service'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Item } from '../../core/shared/item.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { RemoteData } from '../../core/data/remote-data'; +import { of as observableOf } from 'rxjs'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item' + } + ] + } +}); +const routeStub = Object.assign(new ActivatedRouteStub(), { + data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) }) +}); +const metadataServiceStub = { + /* tslint:disable:no-empty */ + processRemoteData: () => {} + /* tslint:enable:no-empty */ +}; + +describe('FullItemPageComponent', () => { + let comp: FullItemPageComponent; + let fixture: ComponentFixture<FullItemPageComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), RouterTestingModule.withRoutes([]), BrowserAnimationsModule], + declarations: [FullItemPageComponent, TruncatePipe, VarDirective], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: ItemDataService, useValue: {}}, + {provide: MetadataService, useValue: metadataServiceStub} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(FullItemPageComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(FullItemPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should display the item\'s metadata', () => { + const table = fixture.debugElement.query(By.css('table')); + for (const metadatum of mockItem.allMetadata([])) { + expect(table.nativeElement.innerHTML).toContain(metadatum.value); + } + }) +}); diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index 6e19a5086486aa877cfed635f32ca380e54ccaa4..b2a42b7c6fe9e02deffac410ea29382e11c2f0be 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -1,9 +1,8 @@ - import {filter, map} from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable , BehaviorSubject } from 'rxjs'; import { ItemPageComponent } from '../simple/item-page.component'; import { MetadataMap } from '../../core/shared/metadata.models'; @@ -32,12 +31,12 @@ import { hasValue } from '../../shared/empty.util'; }) export class FullItemPageComponent extends ItemPageComponent implements OnInit { - itemRD$: Observable<RemoteData<Item>>; + itemRD$: BehaviorSubject<RemoteData<Item>>; metadata$: Observable<MetadataMap>; - constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) { - super(route, items, metadataService); + constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService) { + super(route, router, items, metadataService); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index c60f9d35832d4bb47ce61076821ae8d96795949f..123e3ea1432fe5de3dd4d3e98d4e43e946b1c278 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -1,47 +1,78 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { SharedModule } from '../shared/shared.module'; +import { SharedModule } from './../shared/shared.module'; +import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component'; import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageRoutingModule } from './item-page-routing.module'; -import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; import { MetadataUriValuesComponent } from './field-components/metadata-uri-values/metadata-uri-values.component'; -import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component'; import { ItemPageAuthorFieldComponent } from './simple/field-components/specific-field/author/item-page-author-field.component'; import { ItemPageDateFieldComponent } from './simple/field-components/specific-field/date/item-page-date-field.component'; import { ItemPageAbstractFieldComponent } from './simple/field-components/specific-field/abstract/item-page-abstract-field.component'; import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component'; import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component'; -import { ItemPageSpecificFieldComponent } from './simple/field-components/specific-field/item-page-specific-field.component'; +import { ItemPageFieldComponent } from './simple/field-components/specific-field/item-page-field.component'; import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component'; +import { RelatedItemsComponent } from './simple/related-items/related-items-component'; +import { SearchPageModule } from '../+search-page/search-page.module'; +import { PublicationComponent } from './simple/item-types/publication/publication.component'; +import { PersonComponent } from './simple/item-types/person/person.component'; +import { OrgunitComponent } from './simple/item-types/orgunit/orgunit.component'; +import { ProjectComponent } from './simple/item-types/project/project.component'; +import { JournalComponent } from './simple/item-types/journal/journal.component'; +import { JournalVolumeComponent } from './simple/item-types/journal-volume/journal-volume.component'; +import { JournalIssueComponent } from './simple/item-types/journal-issue/journal-issue.component'; +import { ItemComponent } from './simple/item-types/shared/item.component'; import { EditItemPageModule } from './edit-item-page/edit-item-page.module'; +import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; +import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; @NgModule({ imports: [ CommonModule, SharedModule, EditItemPageModule, - ItemPageRoutingModule + ItemPageRoutingModule, + SearchPageModule ], declarations: [ ItemPageComponent, FullItemPageComponent, - MetadataValuesComponent, MetadataUriValuesComponent, - MetadataFieldWrapperComponent, ItemPageAuthorFieldComponent, ItemPageDateFieldComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, ItemPageTitleFieldComponent, - ItemPageSpecificFieldComponent, + ItemPageFieldComponent, FileSectionComponent, CollectionsComponent, - FullFileSectionComponent + FullFileSectionComponent, + PublicationComponent, + ProjectComponent, + OrgunitComponent, + PersonComponent, + RelatedItemsComponent, + ItemComponent, + GenericItemPageFieldComponent, + JournalComponent, + JournalIssueComponent, + JournalVolumeComponent, + MetadataRepresentationListComponent, + RelatedEntitiesSearchComponent + ], + entryComponents: [ + PublicationComponent, + ProjectComponent, + OrgunitComponent, + PersonComponent, + JournalComponent, + JournalIssueComponent, + JournalVolumeComponent ] }) export class ItemPageModule { diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index c0ee6a84eec9501f3917d46b95112400c340c345..4b7ef23b6991ee171095714dc49f4f1247ef17c0 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -2,9 +2,10 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; import { RemoteData } from '../core/data/remote-data'; -import { getSucceededRemoteData } from '../core/shared/operators'; import { ItemDataService } from '../core/data/item-data.service'; import { Item } from '../core/shared/item.model'; +import { hasValue } from '../shared/empty.util'; +import { find } from 'rxjs/operators'; /** * This class represents a resolver that requests a specific item before the route is activated @@ -18,11 +19,13 @@ export class ItemPageResolver implements Resolve<RemoteData<Item>> { * Method for resolving an item based on the parameters in the current route * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<<RemoteData<Item>> Emits the found item based on the parameters in the current route + * @returns Observable<<RemoteData<Item>> Emits the found item based on the parameters in the current route, + * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Item>> { - return this.itemService.findById(route.params.id).pipe( - getSucceededRemoteData() - ); + return this.itemService.findById(route.params.id) + .pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); } } diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9461ee0950772204df523f8b48f33e2432c6ea6a --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemPageAbstractFieldComponent } from './item-page-abstract-field.component'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; + +let comp: ItemPageAbstractFieldComponent; +let fixture: ComponentFixture<ItemPageAbstractFieldComponent>; + +const mockField = 'dc.description.abstract'; +const mockValue = 'test value'; + +describe('ItemPageAbstractFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageAbstractFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageAbstractFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageAbstractFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts index a8cc309ab69c6ca2cd86f097c69965e254e39d0e..00984d65921a647b31abb35fb4888dc8806bab30 100644 --- a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-abstract-field', - templateUrl: './../item-page-specific-field.component.html' + templateUrl: '../item-page-field.component.html' }) -export class ItemPageAbstractFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the abstract (dc.description.abstract) of an item + */ +export class ItemPageAbstractFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.description.abstract' + */ fields: string[] = [ 'dc.description.abstract' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.abstract'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d865caff8af6d8d5b9ab59b6e5a075f212c6ca35 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts @@ -0,0 +1,45 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageAuthorFieldComponent } from './item-page-author-field.component'; + +let comp: ItemPageAuthorFieldComponent; +let fixture: ComponentFixture<ItemPageAuthorFieldComponent>; + +const mockFields = ['dc.contributor.author', 'dc.creator', 'dc.contributor']; +const mockValue = 'test value'; + +describe('ItemPageAuthorFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageAuthorFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + for (const field of mockFields) { + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageAuthorFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(field, mockValue); + fixture.detectChanges(); + })); + + describe(`when the item contains metadata for ${field}`, () => { + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); + }); + } +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts index e84a52d1b98c1175cd8b117390161424ed898a7d..51941d2cc8b4597e8a0273a7de46ebbf461270ef 100644 --- a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts @@ -1,24 +1,41 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-author-field', - templateUrl: './../item-page-specific-field.component.html' + templateUrl: '../item-page-field.component.html' }) -export class ItemPageAuthorFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the author (dc.contributor.author, dc.creator and dc.contributor) metadata of an item + */ +export class ItemPageAuthorFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.contributor.author', 'dc.creator' and 'dc.contributor' + */ fields: string[] = [ 'dc.contributor.author', 'dc.creator', 'dc.contributor' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.author'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2adada582b0c10b6d5200ee59c5a1ebb9ba1143d --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageDateFieldComponent } from './item-page-date-field.component'; + +let comp: ItemPageDateFieldComponent; +let fixture: ComponentFixture<ItemPageDateFieldComponent>; + +const mockField = 'dc.date.issued'; +const mockValue = 'test value'; + +describe('ItemPageDateFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageDateFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageDateFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageDateFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts index 6950944f879f53ec9a806c798634fb3c6610b401..5a7d56b7dab596fa0f5a3cb8bf1f6e7c2ee79eb9 100644 --- a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-date-field', - templateUrl: './../item-page-specific-field.component.html' + templateUrl: '../item-page-field.component.html' }) -export class ItemPageDateFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the issue date (dc.date.issued) metadata of an item + */ +export class ItemPageDateFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator = ', '; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.date.issued' + */ fields: string[] = [ 'dc.date.issued' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.date'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8abd39cf37d451106fe6371343b38101bc5d45e --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts @@ -0,0 +1,45 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { GenericItemPageFieldComponent } from './generic-item-page-field.component'; + +let comp: GenericItemPageFieldComponent; +let fixture: ComponentFixture<GenericItemPageFieldComponent>; + +const mockValue = 'test value'; +const mockField = 'dc.test'; +const mockLabel = 'test label'; +const mockFields = [mockField]; + +describe('GenericItemPageFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [GenericItemPageFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(GenericItemPageFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(GenericItemPageFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.fields = mockFields; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee7d27a11ff591516a1618ab3e092352fd4792b8 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts @@ -0,0 +1,38 @@ +import { Component, Input } from '@angular/core'; + +import { Item } from '../../../../../core/shared/item.model'; +import { ItemPageFieldComponent } from '../item-page-field.component'; + +@Component({ + selector: 'ds-generic-item-page-field', + templateUrl: '../item-page-field.component.html' +}) +/** + * This component can be used to represent metadata on a simple item page. + * It is the most generic way of displaying metadata values + * It expects 4 parameters: The item, a seperator, the metadata keys and an i18n key + */ +export class GenericItemPageFieldComponent extends ItemPageFieldComponent { + + /** + * The item to display metadata for + */ + @Input() item: Item; + + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ + @Input() separator: string; + + /** + * Fields (schema.element.qualifier) used to render their values. + */ + @Input() fields: string[]; + + /** + * Label i18n key for the rendered metadata + */ + @Input() label: string; + +} diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html similarity index 76% rename from src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html rename to src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html index d6a569198c8c93cd19ebf8f939664530876de7f5..fd3055d197e950b006b630717489d06804ea63c9 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-field.component.html @@ -1,3 +1,3 @@ -<div class="item-page-specific-field"> +<div class="item-page-field"> <ds-metadata-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-values> </div> diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea6e722c66853e9dfcd101fef5df2be68f7aee0c --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts @@ -0,0 +1,63 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { Observable } from 'rxjs'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { ItemPageFieldComponent } from './item-page-field.component'; +import { MetadataValuesComponent } from '../../../field-components/metadata-values/metadata-values.component'; +import { of as observableOf } from 'rxjs'; +import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models'; + +let comp: ItemPageFieldComponent; +let fixture: ComponentFixture<ItemPageFieldComponent>; + +const mockValue = 'test value'; +const mockField = 'dc.test'; +const mockLabel = 'test label'; +const mockFields = [mockField]; + +describe('ItemPageFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.fields = mockFields; + comp.label = mockLabel; + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); + +export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item { + const item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: new MetadataMap() + }); + item.metadata[field] = [{ + language: 'en_US', + value: value + }] as MetadataValue[]; + return item; +} diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts similarity index 82% rename from src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts rename to src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts index f69671a5b5c23e6476e883cd87210d8a3fd4dd32..ce2b110efdd67921949a110192131ebe32963444 100644 --- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -9,10 +9,13 @@ import { Item } from '../../../../core/shared/item.model'; */ @Component({ - templateUrl: './item-page-specific-field.component.html' + templateUrl: './item-page-field.component.html' }) -export class ItemPageSpecificFieldComponent { +export class ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; /** diff --git a/src/app/+item-page/simple/field-components/specific-field/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 aac85d335fd33f57a88f77fae5753e267511b1e0..43bd20d0f691fe7b4adc65c4fffc6d65456b71b0 100644 --- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html @@ -1,3 +1,6 @@ <h2 class="item-page-title-field"> + <div *ngIf="item.firstMetadataValue('relationship.type') as type"> + {{ type.toLowerCase() + '.page.titleprefix' | translate }} + </div> <ds-metadata-values [mdValues]="item?.allMetadata(fields)"></ds-metadata-values> </h2> diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb1ba6a4bc2c7c6976a88f5cdc658c4c6271f525 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageTitleFieldComponent } from './item-page-title-field.component'; + +let comp: ItemPageTitleFieldComponent; +let fixture: ComponentFixture<ItemPageTitleFieldComponent>; + +const mockField = 'dc.title'; +const mockValue = 'test value'; + +describe('ItemPageTitleFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageTitleFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageTitleFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageTitleFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts index be8102359a261d9a94770da684e4dd1764213567..c67d8bcf622eff7490535c28d0bf0a88fc939111 100644 --- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts @@ -1,18 +1,32 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-title-field', templateUrl: './item-page-title-field.component.html' }) -export class ItemPageTitleFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the title (dc.title) of an item + */ +export class ItemPageTitleFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.title' + */ fields: string[] = [ 'dc.title' ]; diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html index a5561b22e5d262a0112ab31e89ed121acdf43095..2b197541276761e3fbc377edd9f7992b204cc896 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 @@ -<div class="item-page-specific-field"> +<div class="item-page-field"> <ds-metadata-uri-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-uri-values> </div> diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4511f16aae01deddcd017b37ef6a039375bad62f --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -0,0 +1,41 @@ +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader'; +import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { ItemPageUriFieldComponent } from './item-page-uri-field.component'; +import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component'; + +let comp: ItemPageUriFieldComponent; +let fixture: ComponentFixture<ItemPageUriFieldComponent>; + +const mockField = 'dc.identifier.uri'; +const mockValue = 'test value'; + +describe('ItemPageUriFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageUriFieldComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageUriFieldComponent); + comp = fixture.componentInstance; + comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + fixture.detectChanges(); + })); + + it('should display display the correct metadata value', () => { + expect(fixture.nativeElement.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts index 4f0633703211d089b1fad0fd3e0db9a43eb0c1dd..c9cd5f1a00db35b687f213a50031ac68482d06ea 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-uri-field', templateUrl: './item-page-uri-field.component.html' }) -export class ItemPageUriFieldComponent extends ItemPageSpecificFieldComponent { +/** + * This component is used for displaying the uri (dc.identifier.uri) metadata of an item + */ +export class ItemPageUriFieldComponent extends ItemPageFieldComponent { + /** + * The item to display metadata for + */ @Input() item: Item; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ separator: string; + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'dc.identifier.uri' + */ fields: string[] = [ 'dc.identifier.uri' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.uri'; } diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html index 98b98a5e32fc9090e637980822da74d5fe55316f..b6de496dc44637e5cecb41b145d017c2c2662d65 100644 --- a/src/app/+item-page/simple/item-page.component.html +++ b/src/app/+item-page/simple/item-page.component.html @@ -1,27 +1,7 @@ <div class="container" *ngVar="(itemRD$ | async) as itemRD"> <div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut> <div *ngIf="itemRD?.payload as item"> - <ds-item-page-title-field [item]="item"></ds-item-page-title-field> - <div class="row"> - <div class="col-xs-12 col-md-4"> - <ds-metadata-field-wrapper> - <ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> - </ds-metadata-field-wrapper> - <ds-item-page-file-section [item]="item"></ds-item-page-file-section> - <ds-item-page-date-field [item]="item"></ds-item-page-date-field> - <ds-item-page-author-field [item]="item"></ds-item-page-author-field> - </div> - <div class="col-xs-12 col-md-6"> - <ds-item-page-abstract-field [item]="item"></ds-item-page-abstract-field> - <ds-item-page-uri-field [item]="item"></ds-item-page-uri-field> - <ds-item-page-collections [item]="item"></ds-item-page-collections> - <div> - <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> - {{"item.page.link.full" | translate}} - </a> - </div> - </div> - </div> + <ds-item-type-switcher [object]="item" [viewMode]="viewMode"></ds-item-type-switcher> </div> </div> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error> diff --git a/src/app/+item-page/simple/item-page.component.scss b/src/app/+item-page/simple/item-page.component.scss index 50be6f5ad03dee5a13636a9571c7a2e4bf85181d..4c26cf08fbb5db8df87bb4c23b8e494802253ff4 100644 --- a/src/app/+item-page/simple/item-page.component.scss +++ b/src/app/+item-page/simple/item-page.component.scss @@ -1 +1,9 @@ @import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +@include media-breakpoint-down(md) { + .container { + width: 100%; + max-width: none; + } +} diff --git a/src/app/+item-page/simple/item-page.component.spec.ts b/src/app/+item-page/simple/item-page.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1202ab7256ee6588f587611f6996c0bc2530497 --- /dev/null +++ b/src/app/+item-page/simple/item-page.component.spec.ts @@ -0,0 +1,91 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ItemPageComponent } from './item-page.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { MetadataService } from '../../core/metadata/metadata.service'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { createRelationshipsObservable } from './item-types/shared/item.component.spec'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() +}); + +describe('ItemPageComponent', () => { + let comp: ItemPageComponent; + let fixture: ComponentFixture<ItemPageComponent>; + + const mockMetadataService = { + /* tslint:disable:no-empty */ + processRemoteData: () => {} + /* tslint:enable:no-empty */ + }; + const mockRoute = Object.assign(new ActivatedRouteStub(), { + data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) }) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), BrowserAnimationsModule], + declarations: [ItemPageComponent, VarDirective], + providers: [ + {provide: ActivatedRoute, useValue: mockRoute}, + {provide: ItemDataService, useValue: {}}, + {provide: MetadataService, useValue: mockMetadataService}, + {provide: Router, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemPageComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the item is loading', () => { + beforeEach(() => { + comp.itemRD$ = observableOf(new RemoteData(true, true, true, null, undefined)); + fixture.detectChanges(); + }); + + it('should display a loading component', () => { + const loading = fixture.debugElement.query(By.css('ds-loading')); + expect(loading.nativeElement).toBeDefined(); + }); + }); + + describe('when the item failed loading', () => { + beforeEach(() => { + comp.itemRD$ = observableOf(new RemoteData(false, false, false, null, undefined)); + fixture.detectChanges(); + }); + + it('should display an error component', () => { + const error = fixture.debugElement.query(By.css('ds-error')); + expect(error.nativeElement).toBeDefined(); + }); + }); + +}); diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 35162b011f19bb4c2d23043bdd48c9f1f30b45c2..89d5977583149e23eafe998295f15c82ea97c7b2 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -1,7 +1,7 @@ -import {mergeMap, filter, map} from 'rxjs/operators'; +import { mergeMap, filter, map, take, tap } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Observable } from 'rxjs'; import { ItemDataService } from '../../core/data/item-data.service'; @@ -14,6 +14,8 @@ import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; import { hasValue } from '../../shared/empty.util'; +import { redirectToPageNotFoundOn404 } from '../../core/shared/operators'; +import { ItemViewMode } from '../../shared/items/item-type-decorator'; /** * This component renders a simple item page. @@ -29,28 +31,33 @@ import { hasValue } from '../../shared/empty.util'; }) export class ItemPageComponent implements OnInit { + /** + * The item's id + */ id: number; - private sub: any; - + /** + * The item wrapped in a remote-data object + */ itemRD$: Observable<RemoteData<Item>>; - thumbnail$: Observable<Bitstream>; + /** + * The view-mode we're currently on + */ + viewMode = ItemViewMode.Full; constructor( private route: ActivatedRoute, + private router: Router, private items: ItemDataService, - private metadataService: MetadataService - ) { - - } + private metadataService: MetadataService, + ) { } ngOnInit(): void { - this.itemRD$ = this.route.data.pipe(map((data) => data.item)); + this.itemRD$ = this.route.data.pipe( + map((data) => data.item as RemoteData<Item>), + redirectToPageNotFoundOn404(this.router) + ); this.metadataService.processRemoteData(this.itemRD$); - this.thumbnail$ = this.itemRD$.pipe( - map((rd: RemoteData<Item>) => rd.payload), - filter((item: Item) => hasValue(item)), - mergeMap((item: Item) => item.getThumbnail()),); } } diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html new file mode 100644 index 0000000000000000000000000000000000000000..5d96abb82b5e77fe21a793eba06106f4bcd82b88 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html @@ -0,0 +1,50 @@ +<h2 class="item-page-title-field"> + {{'journalissue.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="item?.allMetadata(['dc.title'])"></ds-metadata-values> +</h2> +<div class="row"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ds-generic-item-page-field [item]="item" + [fields]="['journalissue.identifier.number']" + [label]="'journalissue.page.number'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journalissue.issuedate']" + [label]="'journalissue.page.issuedate'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journal.title']" + [label]="'journalissue.page.journal-title'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journal.identifier.issn']" + [label]="'journalissue.page.journal-issn'"> + </ds-generic-item-page-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-related-items + [items]="volumes$ | async" + [label]="'relationships.isSingleVolumeOf' | translate"> + </ds-related-items> + <ds-related-items + class="mb-1 mt-1" + [items]="publications$ | async" + [label]="'relationships.isPublicationOfJournalIssue' | translate"> + </ds-related-items> + <ds-generic-item-page-field [item]="item" + [fields]="['journalissue.identifier.description']" + [label]="'journalissue.page.description'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journalissue.identifier.keyword']" + [label]="'journalissue.page.keyword'"> + </ds-generic-item-page-field> + <div> + <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> + {{"item.page.link.full" | translate}} + </a> + </div> + </div> +</div> diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..3575cae797d53c2f3bcd1dcb34f6b91b529db815 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..24b18af96e8cbbfdb52f0d83346a04505e6798aa --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts @@ -0,0 +1,40 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { JournalIssueComponent } from './journal-issue.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'journalissue.identifier.number': [ + { + language: 'en_US', + value: '1234' + } + ], + 'journalissue.issuedate': [ + { + language: 'en_US', + value: '2018' + } + ], + 'journalissue.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ], + 'journalissue.identifier.keyword': [ + { + language: 'en_US', + value: 'keyword' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('JournalIssueComponent', getItemPageFieldsTest(mockItem, JournalIssueComponent)); diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..77ed54d67fc6fa2700c2c02b61d666f9feb8fbdd --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts @@ -0,0 +1,51 @@ +import { Component, Inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('JournalIssue', ItemViewMode.Full) +@Component({ + selector: 'ds-journal-issue', + styleUrls: ['./journal-issue.component.scss'], + templateUrl: './journal-issue.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Issue + */ +export class JournalIssueComponent extends ItemComponent { + /** + * The volumes related to this journal issue + */ + volumes$: Observable<Item[]>; + + /** + * The publications related to this journal issue + */ + publications$: Observable<Item[]>; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.volumes$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isJournalVolumeOfIssue'), + relationsToItems(this.item.id, this.ids) + ); + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfJournalIssue'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html new file mode 100644 index 0000000000000000000000000000000000000000..18bf1701fcd6cb423977db9ef59daf4eb4a54e19 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html @@ -0,0 +1,37 @@ +<h2 class="item-page-title-field"> + {{'journalvolume.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="item?.allMetadata(['dc.title'])"></ds-metadata-values> +</h2> +<div class="row"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ds-generic-item-page-field [item]="item" + [fields]="['journalvolume.identifier.volume']" + [label]="'journalvolume.page.volume'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journalvolume.issuedate']" + [label]="'journalvolume.page.issuedate'"> + </ds-generic-item-page-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-related-items + [items]="journals$ | async" + [label]="'relationships.isSingleJournalOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="issues$ | async" + [label]="'relationships.isIssueOf' | translate"> + </ds-related-items> + <ds-generic-item-page-field [item]="item" + [fields]="['journalvolume.identifier.description']" + [label]="'journalvolume.page.description'"> + </ds-generic-item-page-field> + <div> + <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> + {{"item.page.link.full" | translate}} + </a> + </div> + </div> +</div> diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..3575cae797d53c2f3bcd1dcb34f6b91b529db815 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6f32e9b5f711083a22ab8d6dd2a68496666dd34 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts @@ -0,0 +1,34 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { JournalVolumeComponent } from './journal-volume.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'journalvolume.identifier.volume': [ + { + language: 'en_US', + value: '1234' + } + ], + 'journalvolume.issuedate': [ + { + language: 'en_US', + value: '2018' + } + ], + 'journalvolume.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('JournalVolumeComponent', getItemPageFieldsTest(mockItem, JournalVolumeComponent)); diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..616d96178a46b1faf82f60f3d94eeefe3e59d79a --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts @@ -0,0 +1,51 @@ +import { Component, Inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('JournalVolume', ItemViewMode.Full) +@Component({ + selector: 'ds-journal-volume', + styleUrls: ['./journal-volume.component.scss'], + templateUrl: './journal-volume.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Volume + */ +export class JournalVolumeComponent extends ItemComponent { + /** + * The journals related to this journal volume + */ + journals$: Observable<Item[]>; + + /** + * The journal issues related to this journal volume + */ + issues$: Observable<Item[]>; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.journals$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isJournalOfVolume'), + relationsToItems(this.item.id, this.ids) + ); + this.issues$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isIssueOfJournalVolume'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.html b/src/app/+item-page/simple/item-types/journal/journal.component.html new file mode 100644 index 0000000000000000000000000000000000000000..2ab34302569f406bbd84f1c027198c9a1f2ee44e --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.html @@ -0,0 +1,42 @@ +<h2 class="item-page-title-field"> + {{'journal.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="item?.allMetadata(['dc.title'])"></ds-metadata-values> +</h2> +<div class="row"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ds-generic-item-page-field class="item-page-fields" [item]="item" + [fields]="['journal.identifier.issn']" + [label]="'journal.page.issn'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field class="item-page-fields" [item]="item" + [fields]="['journal.publisher']" + [label]="'journal.page.publisher'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journal.contributor.editor']" + [label]="'journal.page.editor'"> + </ds-generic-item-page-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-related-items + [items]="volumes$ | async" + [label]="'relationships.isVolumeOf' | translate"> + </ds-related-items> + <ds-generic-item-page-field class="item-page-fields" [item]="item" + [fields]="['journal.identifier.description']" + [label]="'journal.page.description'"> + </ds-generic-item-page-field> + <div> + <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> + {{"item.page.link.full" | translate}} + </a> + </div> + </div> + <div class="mt-5 w-100"> + <ds-related-entities-search [item]="item" + [relationType]="'isJournalOfPublication'"> + </ds-related-entities-search> + </div> +</div> diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.scss b/src/app/+item-page/simple/item-types/journal/journal.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..3575cae797d53c2f3bcd1dcb34f6b91b529db815 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..08e8859b3519de286c0b8b98b57b0918f9de1c82 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts @@ -0,0 +1,92 @@ +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { JournalComponent } from './journal.component'; +import { of as observableOf } from 'rxjs'; + +let comp: JournalComponent; +let fixture: ComponentFixture<JournalComponent>; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'journal.identifier.issn': [ + { + language: 'en_US', + value: '1234' + } + ], + 'journal.publisher': [ + { + language: 'en_US', + value: 'a publisher' + } + ], + 'journal.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ] + } +}); + +describe('JournalComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], + providers: [ + {provide: ITEM, useValue: mockItem}, + {provide: ItemDataService, useValue: {}}, + {provide: TruncatableService, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(JournalComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(JournalComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + for (const key of Object.keys(mockItem.metadata)) { + it(`should be calling a component with metadata field ${key}`, () => { + const fields = fixture.debugElement.queryAll(By.css('.item-page-fields')); + expect(containsFieldInput(fields, key)).toBeTruthy(); + }); + } +}); + +function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean { + for (const field of fields) { + const fieldComp = field.componentInstance; + if (isNotEmpty(fieldComp.fields)) { + if (fieldComp.fields.indexOf(metadataKey) > -1) { + return true; + } + } + } + return false; +} diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.ts b/src/app/+item-page/simple/item-types/journal/journal.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..0799f5c7365dc9af810f191b4807595039e68438 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.ts @@ -0,0 +1,42 @@ +import { Component, Inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Journal', ItemViewMode.Full) +@Component({ + selector: 'ds-journal', + styleUrls: ['./journal.component.scss'], + templateUrl: './journal.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Journal + */ +export class JournalComponent extends ItemComponent { + /** + * The volumes related to this journal + */ + volumes$: Observable<Item[]>; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.volumes$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isVolumeOfJournal'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html new file mode 100644 index 0000000000000000000000000000000000000000..0446ac6861f88ca705d279b8e44187a921d148ef --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html @@ -0,0 +1,49 @@ +<h2 class="item-page-title-field"> + {{'orgunit.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="item?.allMetadata(['orgunit.identifier.name'])"></ds-metadata-values> +</h2> +<div class="row"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="this.item.getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ds-generic-item-page-field [item]="item" + [fields]="['orgunit.identifier.dateestablished']" + [label]="'orgunit.page.dateestablished'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['orgunit.identifier.city']" + [label]="'orgunit.page.city'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['orgunit.identifier.country']" + [label]="'orgunit.page.country'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['orgunit.identifier.id']" + [label]="'orgunit.page.id'"> + </ds-generic-item-page-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-related-items + [items]="people$ | async" + [label]="'relationships.isPersonOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="projects$ | async" + [label]="'relationships.isProjectOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="publications$ | async" + [label]="'relationships.isPublicationOf' | translate"> + </ds-related-items> + <ds-generic-item-page-field [item]="item" + [fields]="['orgunit.identifier.description']" + [label]="'orgunit.page.description'"> + </ds-generic-item-page-field> + <div> + <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> + {{"item.page.link.full" | translate}} + </a> + </div> + </div> +</div> diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..3575cae797d53c2f3bcd1dcb34f6b91b529db815 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa5396fb3d653d988948713117bb16f61d9494b8 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts @@ -0,0 +1,46 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { OrgunitComponent } from './orgunit.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'orgunit.identifier.dateestablished': [ + { + language: 'en_US', + value: '2018' + } + ], + 'orgunit.identifier.city': [ + { + language: 'en_US', + value: 'New York' + } + ], + 'orgunit.identifier.country': [ + { + language: 'en_US', + value: 'USA' + } + ], + 'orgunit.identifier.id': [ + { + language: 'en_US', + value: '1' + } + ], + 'orgunit.identifier.description': [ + { + language: 'en_US', + value: 'desc' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('OrgUnitComponent', getItemPageFieldsTest(mockItem, OrgunitComponent)); diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..96dc9a5960e90339cabdfdfe93a88591020b4ffe --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts @@ -0,0 +1,62 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('OrgUnit', ItemViewMode.Full) +@Component({ + selector: 'ds-orgunit', + styleUrls: ['./orgunit.component.scss'], + templateUrl: './orgunit.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Organisation Unit + */ +export class OrgunitComponent extends ItemComponent implements OnInit { + /** + * The people related to this organisation unit + */ + people$: Observable<Item[]>; + + /** + * The projects related to this organisation unit + */ + projects$: Observable<Item[]>; + + /** + * The publications related to this organisation unit + */ + publications$: Observable<Item[]>; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.people$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPersonOfOrgUnit'), + relationsToItems(this.item.id, this.ids) + ); + + this.projects$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isProjectOfOrgUnit'), + relationsToItems(this.item.id, this.ids) + ); + + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfOrgUnit'), + relationsToItems(this.item.id, this.ids) + ); + } + }} diff --git a/src/app/+item-page/simple/item-types/person/person.component.html b/src/app/+item-page/simple/item-types/person/person.component.html new file mode 100644 index 0000000000000000000000000000000000000000..88cd647645ce7a721f80dac60da5aad68efc5302 --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.html @@ -0,0 +1,58 @@ +<h2 class="item-page-title-field"> + {{'person.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="item?.allMetadata(['dc.contributor.author'])"></ds-metadata-values> +</h2> +<div class="row"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="this.item.getThumbnail() | async" [defaultImage]="'assets/images/person-placeholder.svg'"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ds-generic-item-page-field [item]="item" + [fields]="['person.identifier.email']" + [label]="'person.page.email'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['person.identifier.orcid']" + [label]="'person.page.orcid'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['person.identifier.birthdate']" + [label]="'person.page.birthdate'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['person.identifier.staffid']" + [label]="'person.page.staffid'"> + </ds-generic-item-page-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-related-items + [items]="projects$ | async" + [label]="'relationships.isProjectOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="orgUnits$ | async" + [label]="'relationships.isOrgUnitOf' | translate"> + </ds-related-items> + <ds-generic-item-page-field [item]="item" + [fields]="['person.identifier.jobtitle']" + [label]="'person.page.jobtitle'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['person.identifier.lastname']" + [label]="'person.page.lastname'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['person.identifier.firstname']" + [label]="'person.page.firstname'"> + </ds-generic-item-page-field> + <div> + <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> + {{"item.page.link.full" | translate}} + </a> + </div> + </div> + <div class="mt-5 w-100"> + <ds-related-entities-search [item]="item" + [relationType]="'isAuthorOfPublication'"> + </ds-related-entities-search> + </div> +</div> diff --git a/src/app/+item-page/simple/item-types/person/person.component.scss b/src/app/+item-page/simple/item-types/person/person.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..3575cae797d53c2f3bcd1dcb34f6b91b529db815 --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/person/person.component.spec.ts b/src/app/+item-page/simple/item-types/person/person.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf0d5c197db2f295d8139a00d6460587a9d4b8e3 --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.spec.ts @@ -0,0 +1,58 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { PersonComponent } from './person.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'person.identifier.email': [ + { + language: 'en_US', + value: 'fake@email.com' + } + ], + 'person.identifier.orcid': [ + { + language: 'en_US', + value: 'ORCID-1' + } + ], + 'person.identifier.birthdate': [ + { + language: 'en_US', + value: '1993' + } + ], + 'person.identifier.staffid': [ + { + language: 'en_US', + value: '1' + } + ], + 'person.identifier.jobtitle': [ + { + language: 'en_US', + value: 'Developer' + } + ], + 'person.identifier.lastname': [ + { + language: 'en_US', + value: 'Doe' + } + ], + 'person.identifier.firstname': [ + { + language: 'en_US', + value: 'John' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('PersonComponent', getItemPageFieldsTest(mockItem, PersonComponent)); diff --git a/src/app/+item-page/simple/item-types/person/person.component.ts b/src/app/+item-page/simple/item-types/person/person.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..67a2ae7a2e908e24265a98bebce80cd4cf7648fb --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.ts @@ -0,0 +1,77 @@ +import { Component, Inject } from '@angular/core'; +import { Observable , of as observableOf } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Person', ItemViewMode.Full) +@Component({ + selector: 'ds-person', + styleUrls: ['./person.component.scss'], + templateUrl: './person.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Person + */ +export class PersonComponent extends ItemComponent { + /** + * The publications related to this person + */ + publications$: Observable<Item[]>; + + /** + * The projects related to this person + */ + projects$: Observable<Item[]>; + + /** + * The organisation units related to this person + */ + orgUnits$: Observable<Item[]>; + + /** + * The applied fixed filter + */ + fixedFilter$: Observable<string>; + + /** + * The query used for applying the fixed filter + */ + fixedFilterQuery: string; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService, + private fixedFilterService: SearchFixedFilterService + ) { + super(item); + } + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfAuthor'), + relationsToItems(this.item.id, this.ids) + ); + + this.projects$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isProjectOfPerson'), + relationsToItems(this.item.id, this.ids) + ); + + this.orgUnits$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isOrgUnitOfPerson'), + relationsToItems(this.item.id, this.ids) + ); + + this.fixedFilterQuery = this.fixedFilterService.getQueryByRelations('isAuthorOfPublication', this.item.id); + this.fixedFilter$ = observableOf('publication'); + } + } +} diff --git a/src/app/+item-page/simple/item-types/project/project.component.html b/src/app/+item-page/simple/item-types/project/project.component.html new file mode 100644 index 0000000000000000000000000000000000000000..08e386182b22d385a1b1020e7e6a8b24d5a013bf --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.html @@ -0,0 +1,57 @@ +<h2 class="item-page-title-field"> + {{'project.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="item?.allMetadata(['project.identifier.name'])"></ds-metadata-values> +</h2> +<div class="row"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="this.item.getThumbnail() | async" [defaultImage]="'assets/images/project-placeholder.svg'"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ds-generic-item-page-field [item]="item" + [fields]="['project.identifier.status']" + [label]="'project.page.status'"> + </ds-generic-item-page-field> + <ds-metadata-representation-list + [label]="'project.page.contributor' | translate" + [representations]="contributors$ | async"> + </ds-metadata-representation-list> + <ds-generic-item-page-field [item]="item" + [fields]="['project.identifier.funder']" + [label]="'project.page.funder'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['project.identifier.id']" + [label]="'project.page.id'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['project.identifier.expectedcompletion']" + [label]="'project.page.expectedcompletion'"> + </ds-generic-item-page-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-related-items + [items]="people$ | async" + [label]="'relationships.isPersonOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="publications$ | async" + [label]="'relationships.isPublicationOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="orgUnits$ | async" + [label]="'relationships.isOrgUnitOf' | translate"> + </ds-related-items> + <ds-generic-item-page-field [item]="item" + [fields]="['project.identifier.description']" + [label]="'project.page.description'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['project.identifier.keyword']" + [label]="'project.page.keyword'"> + </ds-generic-item-page-field> + <div> + <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> + {{"item.page.link.full" | translate}} + </a> + </div> + </div> +</div> diff --git a/src/app/+item-page/simple/item-types/project/project.component.scss b/src/app/+item-page/simple/item-types/project/project.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..3575cae797d53c2f3bcd1dcb34f6b91b529db815 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/project/project.component.spec.ts b/src/app/+item-page/simple/item-types/project/project.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b54ff9a412e6d2b177182144638bd17efb633a1 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.spec.ts @@ -0,0 +1,46 @@ +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec'; +import { ProjectComponent } from './project.component'; +import { of as observableOf } from 'rxjs'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'project.identifier.status': [ + { + language: 'en_US', + value: 'published' + } + ], + 'project.identifier.id': [ + { + language: 'en_US', + value: '1' + } + ], + 'project.identifier.expectedcompletion': [ + { + language: 'en_US', + value: 'exp comp' + } + ], + 'project.identifier.description': [ + { + language: 'en_US', + value: 'keyword' + } + ], + 'project.identifier.keyword': [ + { + language: 'en_US', + value: 'keyword' + } + ] + }, + relationships: createRelationshipsObservable() +}); + +describe('ProjectComponent', getItemPageFieldsTest(mockItem, ProjectComponent)); diff --git a/src/app/+item-page/simple/item-types/project/project.component.ts b/src/app/+item-page/simple/item-types/project/project.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..eafef36307a5e99050f19a73106d1006dbeb5762 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.ts @@ -0,0 +1,71 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ItemComponent } from '../shared/item.component'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Project', ItemViewMode.Full) +@Component({ + selector: 'ds-project', + styleUrls: ['./project.component.scss'], + templateUrl: './project.component.html' +}) +/** + * The component for displaying metadata and relations of an item of the type Project + */ +export class ProjectComponent extends ItemComponent implements OnInit { + /** + * The contributors related to this project + */ + contributors$: Observable<MetadataRepresentation[]>; + + /** + * The people related to this project + */ + people$: Observable<Item[]>; + + /** + * The publications related to this project + */ + publications$: Observable<Item[]>; + + /** + * The organisation units related to this project + */ + orgUnits$: Observable<Item[]>; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + + ngOnInit(): void { + super.ngOnInit(); + + if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.contributors$ = this.buildRepresentations('OrgUnit', 'project.contributor.other', this.ids); + + this.people$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPersonOfProject'), + relationsToItems(this.item.id, this.ids) + ); + + this.publications$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isPublicationOfProject'), + relationsToItems(this.item.id, this.ids) + ); + + this.orgUnits$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isOrgUnitOfProject'), + relationsToItems(this.item.id, this.ids) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.html b/src/app/+item-page/simple/item-types/publication/publication.component.html new file mode 100644 index 0000000000000000000000000000000000000000..37135c6036b2545f999e33f42a5da524545ed62d --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.html @@ -0,0 +1,60 @@ +<h2 class="item-page-title-field"> + {{'publication.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="item?.allMetadata(['dc.title'])"></ds-metadata-values> +</h2> +<div class="row"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ds-item-page-file-section [item]="item"></ds-item-page-file-section> + <ds-item-page-date-field [item]="item"></ds-item-page-date-field> + <ds-item-page-author-field *ngIf="!(authors$ | async)" [item]="item"></ds-item-page-author-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journal.title']" + [label]="'publication.page.journal-title'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journal.identifier.issn']" + [label]="'publication.page.journal-issn'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['journalvolume.identifier.name']" + [label]="'publication.page.volume-title'"> + </ds-generic-item-page-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-metadata-representation-list + [label]="'relationships.isAuthorOf' | translate" + [representations]="authors$ | async"> + </ds-metadata-representation-list> + <ds-related-items + [items]="projects$ | async" + [label]="'relationships.isProjectOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="orgUnits$ | async" + [label]="'relationships.isOrgUnitOf' | translate"> + </ds-related-items> + <ds-related-items + [items]="journalIssues$ | async" + [label]="'relationships.isJournalIssueOf' | translate"> + </ds-related-items> + <ds-item-page-abstract-field [item]="item"></ds-item-page-abstract-field> + <ds-generic-item-page-field [item]="item" + [fields]="['dc.subject']" + [separator]="','" + [label]="'item.page.subject'"> + </ds-generic-item-page-field> + <ds-generic-item-page-field [item]="item" + [fields]="['dc.identifier.citation']" + [label]="'item.page.citation'"> + </ds-generic-item-page-field> + <ds-item-page-uri-field [item]="item"></ds-item-page-uri-field> + <ds-item-page-collections [item]="item"></ds-item-page-collections> + <div> + <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']"> + {{"item.page.link.full" | translate}} + </a> + </div> + </div> +</div> diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.scss b/src/app/+item-page/simple/item-types/publication/publication.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..3575cae797d53c2f3bcd1dcb34f6b91b529db815 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..48a7a05f459cbef454c0829ce5fd2e457c80aad0 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts @@ -0,0 +1,90 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { By } from '@angular/platform-browser'; +import { createRelationshipsObservable } from '../shared/item.component.spec'; +import { PublicationComponent } from './publication.component'; +import { of as observableOf } from 'rxjs'; +import { MetadataMap } from '../../../../core/shared/metadata.models'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: new MetadataMap(), + relationships: createRelationshipsObservable() +}); + +describe('PublicationComponent', () => { + let comp: PublicationComponent; + let fixture: ComponentFixture<PublicationComponent>; + + const searchFixedFilterServiceStub = { + /* tslint:disable:no-empty */ + getQueryByRelations: () => {} + /* tslint:enable:no-empty */ + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [PublicationComponent, GenericItemPageFieldComponent, TruncatePipe], + providers: [ + {provide: ITEM, useValue: mockItem}, + {provide: ItemDataService, useValue: {}}, + {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub}, + {provide: TruncatableService, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PublicationComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should contain a component to display the date', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the author', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the abstract', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the uri', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the collections', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + +}); diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.ts b/src/app/+item-page/simple/item-types/publication/publication.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..8798b3c1cf78bf752a46cd7474b74e18a2d4a6fd --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.ts @@ -0,0 +1,74 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { + DEFAULT_ITEM_TYPE, ItemViewMode, + rendersItemType +} from '../../../../shared/items/item-type-decorator'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { ItemComponent } from '../shared/item.component'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils'; + +@rendersItemType('Publication', ItemViewMode.Full) +@rendersItemType(DEFAULT_ITEM_TYPE, ItemViewMode.Full) +@Component({ + selector: 'ds-publication', + styleUrls: ['./publication.component.scss'], + templateUrl: './publication.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PublicationComponent extends ItemComponent implements OnInit { + /** + * The authors related to this publication + */ + authors$: Observable<MetadataRepresentation[]>; + + /** + * The projects related to this publication + */ + projects$: Observable<Item[]>; + + /** + * The organisation units related to this publication + */ + orgUnits$: Observable<Item[]>; + + /** + * The journal issues related to this publication + */ + journalIssues$: Observable<Item[]>; + + constructor( + @Inject(ITEM) public item: Item, + private ids: ItemDataService + ) { + super(item); + } + + ngOnInit(): void { + super.ngOnInit(); + + if (this.resolvedRelsAndTypes$) { + + this.authors$ = this.buildRepresentations('Person', 'dc.contributor.author', this.ids); + + this.projects$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isProjectOfPublication'), + relationsToItems(this.item.id, this.ids) + ); + + this.orgUnits$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isOrgUnitOfPublication'), + relationsToItems(this.item.id, this.ids) + ); + + this.journalIssues$ = this.resolvedRelsAndTypes$.pipe( + filterRelationsByTypeLabel('isJournalIssueOfPublication'), + relationsToItems(this.item.id, this.ids) + ); + + } + } +} diff --git a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c632a93657f44dca3ec2624c4a0fafa802d2e89 --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts @@ -0,0 +1,121 @@ +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; +import { getSucceededRemoteData } from '../../../../core/shared/operators'; +import { hasValue } from '../../../../shared/empty.util'; +import { Observable } from 'rxjs/internal/Observable'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { distinctUntilChanged, flatMap, map } from 'rxjs/operators'; +import { of as observableOf, zip as observableZip } from 'rxjs'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; + +/** + * Operator for comparing arrays using a mapping function + * The mapping function should turn the source array into an array of basic types, so that the array can + * be compared using these basic types. + * For example: "(o) => o.id" will compare the two arrays by comparing their content by id. + * @param mapFn Function for mapping the arrays + */ +export const compareArraysUsing = <T>(mapFn: (t: T) => any) => + (a: T[], b: T[]): boolean => { + if (!Array.isArray(a) || ! Array.isArray(b)) { + return false + } + + const aIds = a.map(mapFn); + const bIds = b.map(mapFn); + + return aIds.length === bIds.length && + aIds.every((e) => bIds.includes(e)) && + bIds.every((e) => aIds.includes(e)); + }; + +/** + * Operator for comparing arrays using the object's ids + */ +export const compareArraysUsingIds = <T extends { id: string }>() => + compareArraysUsing((t: T) => hasValue(t) ? t.id : undefined); + +/** + * Fetch the relationships which match the type label given + * @param {string} label Type label + * @returns {(source: Observable<[Relationship[] , RelationshipType[]]>) => Observable<Relationship[]>} + */ +export const filterRelationsByTypeLabel = (label: string) => + (source: Observable<[Relationship[], RelationshipType[]]>): Observable<Relationship[]> => + source.pipe( + map(([relsCurrentPage, relTypesCurrentPage]) => + relsCurrentPage.filter((rel: Relationship, idx: number) => + hasValue(relTypesCurrentPage[idx]) && (relTypesCurrentPage[idx].leftLabel === label || + relTypesCurrentPage[idx].rightLabel === label) + ) + ), + distinctUntilChanged(compareArraysUsingIds()) + ); + +/** + * Operator for turning a list of relationships into a list of the relevant items + * @param {string} thisId The item's id of which the relations belong to + * @param {ItemDataService} ids The ItemDataService to fetch items from the REST API + * @returns {(source: Observable<Relationship[]>) => Observable<Item[]>} + */ +export const relationsToItems = (thisId: string, ids: ItemDataService) => + (source: Observable<Relationship[]>): Observable<Item[]> => + source.pipe( + flatMap((rels: Relationship[]) => + observableZip( + ...rels.map((rel: Relationship) => { + let queryId = rel.leftId; + if (rel.leftId === thisId) { + queryId = rel.rightId; + } + return ids.findById(queryId); + }) + ) + ), + map((arr: Array<RemoteData<Item>>) => + arr + .filter((d: RemoteData<Item>) => d.hasSucceeded) + .map((d: RemoteData<Item>) => d.payload)), + distinctUntilChanged(compareArraysUsingIds()), + ); + +/** + * Operator for turning a list of relationships into a list of metadatarepresentations given the original metadata + * @param parentId The id of the parent item + * @param itemType The type of relation this list resembles (for creating representations) + * @param metadata The list of original Metadatum objects + * @param ids The ItemDataService to use for fetching Items from the Rest API + */ +export const relationsToRepresentations = (parentId: string, itemType: string, metadata: MetadataValue[], ids: ItemDataService) => + (source: Observable<Relationship[]>): Observable<MetadataRepresentation[]> => + source.pipe( + flatMap((rels: Relationship[]) => + observableZip( + ...metadata + .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) + .map((metadatum: MetadataValue) => { + if (metadatum.isVirtual) { + const matchingRels = rels.filter((rel: Relationship) => ('' + rel.id) === metadatum.virtualValue); + if (matchingRels.length > 0) { + const matchingRel = matchingRels[0]; + let queryId = matchingRel.leftId; + if (matchingRel.leftId === parentId) { + queryId = matchingRel.rightId; + } + return ids.findById(queryId).pipe( + getSucceededRemoteData(), + map((d: RemoteData<Item>) => Object.assign(new ItemMetadataRepresentation(), d.payload)) + ); + } + } else { + return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum)); + } + }) + ) + ) + ); diff --git a/src/app/+item-page/simple/item-types/shared/item.component.spec.ts b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6b4dd801df07c7921803952984471b82d55d087 --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts @@ -0,0 +1,428 @@ +import { Item } from '../../../../core/shared/item.model'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader'; +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { ItemComponent } from './item.component'; +import { of as observableOf } from 'rxjs'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { Observable } from 'rxjs/internal/Observable'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models'; +import { compareArraysUsing, compareArraysUsingIds } from './item-relationships-utils'; + +/** + * Create a generic test for an item-page-fields component using a mockItem and the type of component + * @param {Item} mockItem The item to use for testing. The item needs to contain just the metadata necessary to + * execute the tests for it's component. + * @param component The type of component to create test cases for. + * @returns {() => void} Returns a specDefinition for the test. + */ +export function getItemPageFieldsTest(mockItem: Item, component) { + return () => { + let comp: any; + let fixture: ComponentFixture<any>; + + const searchFixedFilterServiceStub = { + /* tslint:disable:no-empty */ + getQueryByRelations: () => {} + /* tslint:enable:no-empty */ + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + })], + declarations: [component, GenericItemPageFieldComponent, TruncatePipe], + providers: [ + {provide: ITEM, useValue: mockItem}, + {provide: ItemDataService, useValue: {}}, + {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub}, + {provide: TruncatableService, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(component, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(component); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + for (const key of Object.keys(mockItem.metadata)) { + it(`should be calling a component with metadata field ${key}`, () => { + const fields = fixture.debugElement.queryAll(By.css('ds-generic-item-page-field')); + expect(containsFieldInput(fields, key)).toBeTruthy(); + }); + } + } +} + +/** + * Checks whether in a list of debug elements, at least one of them contains a specific metadata key in their + * fields property. + * @param {DebugElement[]} fields List of debug elements to check + * @param {string} metadataKey A metadata key to look for + * @returns {boolean} + */ +export function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean { + for (const field of fields) { + const fieldComp = field.componentInstance; + if (isNotEmpty(fieldComp.fields)) { + if (fieldComp.fields.indexOf(metadataKey) > -1) { + return true; + } + } + } + return false; +} + +export function createRelationshipsObservable() { + return observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [ + Object.assign(new Relationship(), { + relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType())) + }) + ]))); +} +describe('ItemComponent', () => { + const arr1 = [ + { + id: 1, + name: 'test' + }, + { + id: 2, + name: 'another test' + }, + { + id: 3, + name: 'one last test' + } + ]; + const arrWithWrongId = [ + { + id: 1, + name: 'test' + }, + { + id: 5, // Wrong id on purpose + name: 'another test' + }, + { + id: 3, + name: 'one last test' + } + ]; + const arrWithWrongName = [ + { + id: 1, + name: 'test' + }, + { + id: 2, + name: 'wrong test' // Wrong name on purpose + }, + { + id: 3, + name: 'one last test' + } + ]; + const arrWithDifferentOrder = [arr1[0], arr1[2], arr1[1]]; + const arrWithOneMore = [...arr1, { + id: 4, + name: 'fourth test' + }]; + const arrWithAddedProperties = [ + { + id: 1, + name: 'test', + extra: 'extra property' + }, + { + id: 2, + name: 'another test', + extra: 'extra property' + }, + { + id: 3, + name: 'one last test', + extra: 'extra property' + } + ]; + const arrOfPrimitiveTypes = [1, 2, 3, 4]; + const arrOfPrimitiveTypesWithOneWrong = [1, 5, 3, 4]; + const arrOfPrimitiveTypesWithDifferentOrder = [1, 3, 2, 4]; + const arrOfPrimitiveTypesWithOneMore = [1, 2, 3, 4, 5]; + + describe('when calling compareArraysUsing', () => { + + describe('and comparing by id', () => { + const compare = compareArraysUsing<any>((o) => o.id); + + it('should return true when comparing the same array', () => { + expect(compare(arr1, arr1)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy(); + }); + + it('should return true regardless of other properties being different', () => { + expect(compare(arr1, arrWithWrongName)).toBeTruthy(); + }); + + it('should return true regardless of extra properties', () => { + expect(compare(arr1, arrWithAddedProperties)).toBeTruthy(); + }); + + it('should return false when the ids don\'t match', () => { + expect(compare(arr1, arrWithWrongId)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1, arrWithOneMore)).toBeFalsy(); + }); + }); + + describe('and comparing by name', () => { + const compare = compareArraysUsing<any>((o) => o.name); + + it('should return true when comparing the same array', () => { + expect(compare(arr1, arr1)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy(); + }); + + it('should return true regardless of other properties being different', () => { + expect(compare(arr1, arrWithWrongId)).toBeTruthy(); + }); + + it('should return true regardless of extra properties', () => { + expect(compare(arr1, arrWithAddedProperties)).toBeTruthy(); + }); + + it('should return false when the names don\'t match', () => { + expect(compare(arr1, arrWithWrongName)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1, arrWithOneMore)).toBeFalsy(); + }); + }); + + describe('and comparing by full objects', () => { + const compare = compareArraysUsing<any>((o) => o); + + it('should return true when comparing the same array', () => { + expect(compare(arr1, arr1)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy(); + }); + + it('should return false when extra properties are added', () => { + expect(compare(arr1, arrWithAddedProperties)).toBeFalsy(); + }); + + it('should return false when the ids don\'t match', () => { + expect(compare(arr1, arrWithWrongId)).toBeFalsy(); + }); + + it('should return false when the names don\'t match', () => { + expect(compare(arr1, arrWithWrongName)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1, arrWithOneMore)).toBeFalsy(); + }); + }); + + describe('and comparing with primitive objects as source', () => { + const compare = compareArraysUsing<any>((o) => o); + + it('should return true when comparing the same array', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypes)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithDifferentOrder)).toBeTruthy(); + }); + + it('should return false when at least one is wrong', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneWrong)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneMore)).toBeFalsy(); + }); + }); + + }); + + describe('when calling compareArraysUsingIds', () => { + const compare = compareArraysUsingIds(); + + it('should return true when comparing the same array', () => { + expect(compare(arr1 as any, arr1 as any)).toBeTruthy(); + }); + + it('should return true regardless of the order', () => { + expect(compare(arr1 as any, arrWithDifferentOrder as any)).toBeTruthy(); + }); + + it('should return true regardless of other properties being different', () => { + expect(compare(arr1 as any, arrWithWrongName as any)).toBeTruthy(); + }); + + it('should return true regardless of extra properties', () => { + expect(compare(arr1 as any, arrWithAddedProperties as any)).toBeTruthy(); + }); + + it('should return false when the ids don\'t match', () => { + expect(compare(arr1 as any, arrWithWrongId as any)).toBeFalsy(); + }); + + it('should return false when the sizes don\'t match', () => { + expect(compare(arr1 as any, arrWithOneMore as any)).toBeFalsy(); + }); + }); + + describe('when calling buildRepresentations', () => { + let comp: ItemComponent; + let fixture: ComponentFixture<ItemComponent>; + + const metadataField = 'dc.contributor.author'; + const mockItem = Object.assign(new Item(), { + id: '1', + uuid: '1', + metadata: new MetadataMap(), + relationships: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [ + Object.assign(new Relationship(), { + uuid: '123', + id: '123', + leftId: '1', + rightId: '2', + relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType())) + }) + ]))) + }); + mockItem.metadata[metadataField] = [ + { + value: 'Second value', + place: 1 + }, + { + value: 'Third value', + place: 2, + authority: 'virtual::123' + }, + { + value: 'First value', + place: 0 + }, + { + value: 'Fourth value', + place: 3, + authority: '123' + } + ] as MetadataValue[]; + const relatedItem = Object.assign(new Item(), { + id: '2', + metadata: Object.assign(new MetadataMap(), { + 'dc.title': [ + { + language: 'en_US', + value: 'related item' + } + ] + }) + }); + const mockItemDataService = Object.assign({ + findById: (id) => { + if (id === relatedItem.id) { + return observableOf(new RemoteData(false, false, true, null, relatedItem)) + } + } + }) as ItemDataService; + + let representations: Observable<MetadataRepresentation[]>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), BrowserAnimationsModule], + declarations: [ItemComponent, VarDirective], + providers: [ + {provide: ITEM, useValue: mockItem} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + representations = comp.buildRepresentations('bogus', metadataField, mockItemDataService); + })); + + it('should contain exactly 4 metadata-representations', () => { + representations.subscribe((reps: MetadataRepresentation[]) => { + expect(reps.length).toEqual(4); + }); + }); + + it('should have all the representations in the correct order', () => { + representations.subscribe((reps: MetadataRepresentation[]) => { + expect(reps[0].getValue()).toEqual('First value'); + expect(reps[1].getValue()).toEqual('Second value'); + expect(reps[2].getValue()).toEqual('related item'); + expect(reps[3].getValue()).toEqual('Fourth value'); + }); + }); + + it('should have created the correct MetadatumRepresentation and ItemMetadataRepresentation objects for the correct Metadata', () => { + representations.subscribe((reps: MetadataRepresentation[]) => { + expect(reps[0] instanceof MetadatumRepresentation).toEqual(true); + expect(reps[1] instanceof MetadatumRepresentation).toEqual(true); + expect(reps[2] instanceof ItemMetadataRepresentation).toEqual(true); + expect(reps[3] instanceof MetadatumRepresentation).toEqual(true); + }); + }); + }) + +}); diff --git a/src/app/+item-page/simple/item-types/shared/item.component.ts b/src/app/+item-page/simple/item-types/shared/item.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6d43aa6b3a2579bd6e5064fb5b3fa5994d4503f --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item.component.ts @@ -0,0 +1,79 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { combineLatest as observableCombineLatest, Observable, zip as observableZip } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map } from 'rxjs/operators'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { Item } from '../../../../core/shared/item.model'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component'; +import { compareArraysUsingIds, relationsToRepresentations } from './item-relationships-utils'; + +@Component({ + selector: 'ds-item', + template: '' +}) +/** + * A generic component for displaying metadata and relations of an item + */ +export class ItemComponent implements OnInit { + /** + * Resolved relationships and types together in one observable + */ + resolvedRelsAndTypes$: Observable<[Relationship[], RelationshipType[]]>; + + constructor( + @Inject(ITEM) public item: Item + ) {} + + ngOnInit(): void { + const relationships$ = this.item.relationships; + if (relationships$) { + const relsCurrentPage$ = relationships$.pipe( + filter((rd: RemoteData<PaginatedList<Relationship>>) => rd.hasSucceeded), + getRemoteDataPayload(), + map((pl: PaginatedList<Relationship>) => pl.page), + distinctUntilChanged(compareArraysUsingIds()) + ); + + const relTypesCurrentPage$ = relsCurrentPage$.pipe( + flatMap((rels: Relationship[]) => + observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe( + map(([...arr]: Array<RemoteData<RelationshipType>>) => arr.map((d: RemoteData<RelationshipType>) => d.payload)) + ) + ), + distinctUntilChanged(compareArraysUsingIds()) + ); + + this.resolvedRelsAndTypes$ = observableCombineLatest( + relsCurrentPage$, + relTypesCurrentPage$ + ); + } + } + + /** + * Build a list of MetadataRepresentations for the current item. This combines all metadata and relationships of a + * certain type. + * @param itemType The type of item we're building representations of. Used for matching templates. + * @param metadataField The metadata field that resembles the item type. + * @param itemDataService ItemDataService to turn relations into items. + */ + buildRepresentations(itemType: string, metadataField: string, itemDataService: ItemDataService): Observable<MetadataRepresentation[]> { + const metadata = this.item.findMetadataSortedByPlace(metadataField); + const relsCurrentPage$ = this.item.relationships.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((pl: PaginatedList<Relationship>) => pl.page), + distinctUntilChanged(compareArraysUsingIds()) + ); + + return relsCurrentPage$.pipe( + relationsToRepresentations(this.item.id, itemType, metadata, itemDataService) + ); + } + +} diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html new file mode 100644 index 0000000000000000000000000000000000000000..48eabf8451d1fc5b765c5a3cb5729e6591986ec5 --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html @@ -0,0 +1,5 @@ +<ds-metadata-field-wrapper *ngIf="representations && representations.length > 0" [label]="label"> + <ds-item-type-switcher *ngFor="let rep of representations" + [object]="rep" [viewMode]="viewMode"> + </ds-item-type-switcher> +</ds-metadata-field-wrapper> diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f02625e8c72a9edeb3740fccbd96d1c12714c22c --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts @@ -0,0 +1,40 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { MetadataRepresentationListComponent } from './metadata-representation-list.component'; +import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; + +const itemType = 'type'; +const metadataRepresentation1 = new MetadatumRepresentation(itemType); +const metadataRepresentation2 = new ItemMetadataRepresentation(); +const representations = [metadataRepresentation1, metadataRepresentation2]; + +describe('MetadataRepresentationListComponent', () => { + let comp: MetadataRepresentationListComponent; + let fixture: ComponentFixture<MetadataRepresentationListComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [MetadataRepresentationListComponent], + providers: [], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataRepresentationListComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(MetadataRepresentationListComponent); + comp = fixture.componentInstance; + comp.representations = representations; + fixture.detectChanges(); + })); + + it(`should load ${representations.length} item-type-switcher components`, () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher')); + expect(fields.length).toBe(representations.length); + }); + +}); diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0dc222bf12a526ce7cfac8078e8248271141bc7 --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -0,0 +1,29 @@ +import { Component, Input } from '@angular/core'; +import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; +import { ItemViewMode } from '../../../shared/items/item-type-decorator'; + +@Component({ + selector: 'ds-metadata-representation-list', + templateUrl: './metadata-representation-list.component.html' +}) +/** + * This component is used for displaying metadata + * It expects a list of MetadataRepresentation objects and a label to put on top of the list + */ +export class MetadataRepresentationListComponent { + /** + * A list of metadata-representations to display + */ + @Input() representations: MetadataRepresentation[]; + + /** + * An i18n label to use as a title for the list + */ + @Input() label: string; + + /** + * The view-mode we're currently on + * @type {ElementViewMode} + */ + viewMode = ItemViewMode.Metadata; +} diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html new file mode 100644 index 0000000000000000000000000000000000000000..9ec082db733d9eecd468b6d44b1a18d334e96202 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html @@ -0,0 +1,6 @@ +<ds-filtered-search-page + [fixedFilterQuery]="fixedFilter" + [fixedFilter$]="fixedFilter$" + [searchEnabled]="searchEnabled" + [sideBarWidth]="sideBarWidth"> +</ds-filtered-search-page> diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e76a9cf3d069325dd339de36e09748502c071fe5 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts @@ -0,0 +1,56 @@ +import { RelatedEntitiesSearchComponent } from './related-entities-search.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { Item } from '../../../../core/shared/item.model'; + +describe('RelatedEntitiesSearchComponent', () => { + let comp: RelatedEntitiesSearchComponent; + let fixture: ComponentFixture<RelatedEntitiesSearchComponent>; + let fixedFilterService: SearchFixedFilterService; + + const mockItem = Object.assign(new Item(), { + id: 'id1' + }); + const mockRelationType = 'publicationsOfAuthor'; + const mockRelationEntityType = 'publication'; + const mockFilter= `f.${mockRelationType}=${mockItem.id}`; + const fixedFilterServiceStub = { + getFilterByRelation: () => mockFilter + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [RelatedEntitiesSearchComponent], + providers: [ + { provide: SearchFixedFilterService, useValue: fixedFilterServiceStub } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(RelatedEntitiesSearchComponent); + comp = fixture.componentInstance; + fixedFilterService = (comp as any).fixedFilterService; + comp.relationType = mockRelationType; + comp.item = mockItem; + comp.relationEntityType = mockRelationEntityType; + fixture.detectChanges(); + }); + + it('should create a fixedFilter', () => { + expect(comp.fixedFilter).toEqual(mockFilter); + }); + + it('should create a fixedFilter$', () => { + comp.fixedFilter$.subscribe((fixedFilter) => { + expect(fixedFilter).toEqual(mockRelationEntityType); + }) + }); + +}); diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..672655a8b83adbcb82c4482981f10e7cd5f1a96e --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts @@ -0,0 +1,64 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Item } from '../../../../core/shared/item.model'; +import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { of } from 'rxjs/internal/observable/of'; + +@Component({ + selector: 'ds-related-entities-search', + templateUrl: './related-entities-search.component.html' +}) +/** + * A component to show related items as search results. + * Related items can be facetted, or queried using an + * optional search box. + */ +export class RelatedEntitiesSearchComponent implements OnInit { + + /** + * The type of relationship to fetch items for + * e.g. 'isAuthorOfPublication' + */ + @Input() relationType: string; + + /** + * The item to render relationships for + */ + @Input() item: Item; + + /** + * The entity type of the relationship items to be displayed + * e.g. 'publication' + * This determines the title of the search results (if search is enabled) + */ + @Input() relationEntityType: string; + + /** + * Whether or not the search bar and title should be displayed (defaults to true) + * @type {boolean} + */ + @Input() searchEnabled = true; + + /** + * The ratio of the sidebar's width compared to the search results (1-12) (defaults to 4) + * @type {number} + */ + @Input() sideBarWidth = 4; + + fixedFilter: string; + fixedFilter$: Observable<string>; + + constructor(private fixedFilterService: SearchFixedFilterService) { + } + + ngOnInit(): void { + if (isNotEmpty(this.relationType) && isNotEmpty(this.item)) { + this.fixedFilter = this.fixedFilterService.getFilterByRelation(this.relationType, this.item.id); + } + if (isNotEmpty(this.relationEntityType)) { + this.fixedFilter$ = of(this.relationEntityType); + } + } + +} diff --git a/src/app/+item-page/simple/related-items/related-items-component.ts b/src/app/+item-page/simple/related-items/related-items-component.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b54d7316a9805afd2eaf09c7795682fbd7a19d3 --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items-component.ts @@ -0,0 +1,30 @@ +import { Component, Input } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ItemViewMode } from '../../../shared/items/item-type-decorator'; + +@Component({ + selector: 'ds-related-items', + styleUrls: ['./related-items.component.scss'], + templateUrl: './related-items.component.html' +}) +/** + * This component is used for displaying relations between items + * It expects a list of items to display and a label to put on top + */ +export class RelatedItemsComponent { + /** + * A list of items to display + */ + @Input() items: Item[]; + + /** + * An i18n label to use as a title for the list (usually describes the relation) + */ + @Input() label: string; + + /** + * The view-mode we're currently on + * @type {ElementViewMode} + */ + viewMode = ItemViewMode.Element; +} diff --git a/src/app/+item-page/simple/related-items/related-items.component.html b/src/app/+item-page/simple/related-items/related-items.component.html new file mode 100644 index 0000000000000000000000000000000000000000..4b284ad63c59017f40e84cba1568242f89883378 --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items.component.html @@ -0,0 +1,5 @@ +<ds-metadata-field-wrapper *ngIf="items && items.length > 0" [label]="label"> + <ds-item-type-switcher *ngFor="let item of items" + [object]="item" [viewMode]="viewMode"> + </ds-item-type-switcher> +</ds-metadata-field-wrapper> diff --git a/src/app/+item-page/simple/related-items/related-items.component.scss b/src/app/+item-page/simple/related-items/related-items.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/+item-page/simple/related-items/related-items.component.spec.ts b/src/app/+item-page/simple/related-items/related-items.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef42ab1098482e8ece1cbbafda0d606a39763e92 --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items.component.spec.ts @@ -0,0 +1,51 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { RelatedItemsComponent } from './related-items-component'; +import { Item } from '../../../core/shared/item.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { By } from '@angular/platform-browser'; +import { createRelationshipsObservable } from '../item-types/shared/item.component.spec'; +import { of as observableOf } from 'rxjs'; + +const mockItem1: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() +}); +const mockItem2: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() +}); +const mockItems = [mockItem1, mockItem2]; + +describe('RelatedItemsComponent', () => { + let comp: RelatedItemsComponent; + let fixture: ComponentFixture<RelatedItemsComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [RelatedItemsComponent], + providers: [], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(RelatedItemsComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(RelatedItemsComponent); + comp = fixture.componentInstance; + comp.items = mockItems; + fixture.detectChanges(); + })); + + it(`should load ${mockItems.length} item-type-switcher components`, () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher')); + expect(fields.length).toBe(mockItems.length); + }); + +}); diff --git a/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts new file mode 100644 index 0000000000000000000000000000000000000000..baf2f0b9208964b6a5c7adb2df580e062eb17912 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts @@ -0,0 +1,4 @@ +export enum MyDSpaceConfigurationValueType { + Workspace = 'workspace', + Workflow = 'workflow' +} diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..38d6769437d2f04ac7b633f1673dac1f9fdb428d --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts @@ -0,0 +1,259 @@ +import { of as observableOf } from 'rxjs'; + +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { SearchFilter } from '../+search-page/search-filter.model'; +import { ActivatedRouteStub } from '../shared/testing/active-router-stub'; +import { MockRoleService } from '../shared/mocks/mock-role-service'; +import { cold, hot } from 'jasmine-marbles'; +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; + +describe('MyDSpaceConfigurationService', () => { + let service: MyDSpaceConfigurationService; + const value1 = 'random value'; + const prefixFilter = { + 'f.namedresourcetype': ['another value'], + 'f.dateSubmitted.min': ['2013'], + 'f.dateSubmitted.max': ['2018'] + }; + const defaults = new PaginatedSearchOptions({ + pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }), + sort: new SortOptions('score', SortDirection.DESC), + query: '', + scope: '' + }); + + const backendFilters = [new SearchFilter('f.namedresourcetype', ['another value']), new SearchFilter('f.dateSubmitted', ['[2013 TO 2018]'])]; + + const spy = jasmine.createSpyObj('RouteService', { + getQueryParameterValue: observableOf(value1), + getQueryParamsWithPrefix: observableOf(prefixFilter), + getRouteParameterValue: observableOf(''), + getRouteDataValue: observableOf({}) + }); + + const activatedRoute: any = new ActivatedRouteStub(); + + const roleService: any = new MockRoleService(); + + const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { + getQueryByFilterName: observableOf(''), + }); + + beforeEach(() => { + service = new MyDSpaceConfigurationService(roleService, fixedFilterService, spy, activatedRoute); + }); + + describe('when the scope is called', () => { + beforeEach(() => { + service.getCurrentScope(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'scope\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('scope'); + }); + }); + + describe('when getCurrentConfiguration is called', () => { + beforeEach(() => { + service.getCurrentConfiguration(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'configuration\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('configuration'); + }); + }); + + describe('when getCurrentQuery is called', () => { + beforeEach(() => { + service.getCurrentQuery(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'query\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('query'); + }); + }); + + describe('when getCurrentDSOType is called', () => { + beforeEach(() => { + service.getCurrentDSOType(); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'dsoType\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('dsoType'); + }); + }); + + describe('when getCurrentFrontendFilters is called', () => { + beforeEach(() => { + service.getCurrentFrontendFilters(); + }); + it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => { + expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + }); + }); + + describe('when getCurrentFilters is called', () => { + let parsedValues$; + beforeEach(() => { + parsedValues$ = service.getCurrentFilters(); + }); + it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => { + expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + parsedValues$.subscribe((values) => { + expect(values).toEqual(backendFilters); + }); + }); + }); + + describe('when getCurrentSort is called', () => { + beforeEach(() => { + service.getCurrentSort({} as any); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'sortDirection\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortDirection'); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'sortField\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField'); + }); + }); + + describe('when getCurrentPagination is called', () => { + beforeEach(() => { + service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'page\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('page'); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'pageSize\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize'); + }); + }); + + describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => { + beforeEach(() => { + spyOn(service, 'getCurrentPagination').and.callThrough(); + spyOn(service, 'getCurrentSort').and.callThrough(); + spyOn(service, 'getCurrentScope').and.callThrough(); + spyOn(service, 'getCurrentConfiguration').and.callThrough(); + spyOn(service, 'getCurrentQuery').and.callThrough(); + spyOn(service, 'getCurrentDSOType').and.callThrough(); + spyOn(service, 'getCurrentFilters').and.callThrough(); + }); + + describe('when subscribeToSearchOptions is called', () => { + beforeEach(() => { + (service as any).subscribeToSearchOptions(defaults) + }); + it('should call all getters it needs, but not call any others', () => { + expect(service.getCurrentPagination).not.toHaveBeenCalled(); + expect(service.getCurrentSort).not.toHaveBeenCalled(); + expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); + expect(service.getCurrentQuery).toHaveBeenCalled(); + expect(service.getCurrentDSOType).toHaveBeenCalled(); + expect(service.getCurrentFilters).toHaveBeenCalled(); + }); + }); + + describe('when subscribeToPaginatedSearchOptions is called', () => { + beforeEach(() => { + (service as any).subscribeToPaginatedSearchOptions(defaults); + }); + it('should call all getters it needs', () => { + expect(service.getCurrentPagination).toHaveBeenCalled(); + expect(service.getCurrentSort).toHaveBeenCalled(); + expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); + expect(service.getCurrentQuery).toHaveBeenCalled(); + expect(service.getCurrentDSOType).toHaveBeenCalled(); + expect(service.getCurrentFilters).toHaveBeenCalled(); + }); + }); + }); + + describe('when getAvailableConfigurationTypes is called', () => { + + it('should return properly list when user is submitter', () => { + roleService.setSubmitter(true); + roleService.setController(false); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workspace + ] + })); + }); + + it('should return properly list when user is controller', () => { + roleService.setSubmitter(false); + roleService.setController(true); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + + it('should return properly list when user is admin', () => { + roleService.setSubmitter(false); + roleService.setController(false); + roleService.setAdmin(true); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + + it('should return properly list when user is submitter and controller', () => { + roleService.setSubmitter(true); + roleService.setController(true); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + }); + + describe('when getAvailableConfigurationOptions is called', () => { + + it('should return properly options list', () => { + spyOn(service, 'getAvailableConfigurationTypes').and.returnValue(hot('a', { + a: [ + MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.Workflow + ] + })); + + const list$ = service.getAvailableConfigurationOptions(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + { + value: MyDSpaceConfigurationValueType.Workspace, + label: `mydspace.show.${MyDSpaceConfigurationValueType.Workspace}` + }, + { + value: MyDSpaceConfigurationValueType.Workflow, + label: `mydspace.show.${MyDSpaceConfigurationValueType.Workflow}` + } + ] + })); + }); + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..705ec897f89efe25d040558b55ca05c11374181d --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { combineLatest, Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; +import { RoleService } from '../core/roles/role.service'; +import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { RouteService } from '../shared/services/route.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; + +/** + * Service that performs all actions that have to do with the current mydspace configuration + */ +@Injectable() +export class MyDSpaceConfigurationService extends SearchConfigurationService { + /** + * Default pagination settings + */ + protected defaultPagination = Object.assign(new PaginationComponentOptions(), { + id: 'mydspace-page', + pageSize: 10, + currentPage: 1 + }); + + /** + * Default sort settings + */ + protected defaultSort = new SortOptions('dc.date.issued', SortDirection.DESC); + + /** + * Default configuration parameter setting + */ + protected defaultConfiguration = 'workspace'; + + /** + * Default scope setting + */ + protected defaultScope = ''; + + /** + * Default query setting + */ + protected defaultQuery = ''; + + private isAdmin$: Observable<boolean>; + private isController$: Observable<boolean>; + private isSubmitter$: Observable<boolean>; + + /** + * Initialize class + * + * @param {roleService} roleService + * @param {SearchFixedFilterService} fixedFilterService + * @param {RouteService} routeService + * @param {ActivatedRoute} route + */ + constructor(protected roleService: RoleService, + protected fixedFilterService: SearchFixedFilterService, + protected routeService: RouteService, + protected route: ActivatedRoute) { + + super(routeService, fixedFilterService, route); + + // override parent class initialization + this._defaults = null; + this.initDefaults(); + + this.isSubmitter$ = this.roleService.isSubmitter(); + this.isController$ = this.roleService.isController(); + this.isAdmin$ = this.roleService.isAdmin(); + } + + /** + * Returns the list of available configuration depend on the user role + * + * @return {Observable<MyDSpaceConfigurationValueType[]>} + * Emits the available configuration list + */ + public getAvailableConfigurationTypes(): Observable<MyDSpaceConfigurationValueType[]> { + return combineLatest(this.isSubmitter$, this.isController$, this.isAdmin$).pipe( + first(), + map(([isSubmitter, isController, isAdmin]: [boolean, boolean, boolean]) => { + const availableConf: MyDSpaceConfigurationValueType[] = []; + if (isSubmitter) { + availableConf.push(MyDSpaceConfigurationValueType.Workspace); + } + if (isController || isAdmin) { + availableConf.push(MyDSpaceConfigurationValueType.Workflow); + } + return availableConf; + })); + } + + /** + * Returns the select options for the available configuration list + * + * @return {Observable<SearchConfigurationOption[]>} + * Emits the select options list + */ + public getAvailableConfigurationOptions(): Observable<SearchConfigurationOption[]> { + return this.getAvailableConfigurationTypes().pipe( + first(), + map((availableConfigurationTypes: MyDSpaceConfigurationValueType[]) => { + const configurationOptions: SearchConfigurationOption[] = []; + availableConfigurationTypes.forEach((type) => { + const value = type; + const label = `mydspace.show.${value}`; + configurationOptions.push({ value, label }); + }); + return configurationOptions; + }) + ) + } + +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html new file mode 100644 index 0000000000000000000000000000000000000000..280d694d27260c1db727449035aa359d754920fc --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -0,0 +1,15 @@ +<div class="parent mb-3"> + <div class="upload"> + <ds-uploader *ngIf="uploadFilesOptions.url !== ''" + [uploadFilesOptions]="uploadFilesOptions" + (onCompleteItem)="onCompleteItem($event)" + (onUploadError)="onUploadError($event)"></ds-uploader> + + </div> + <div class="add"> + <a class="btn btn-lg btn-primary mt-1 ml-2" [routerLink]="['/submit']" role="button"> + <i class="fa fa-plus-circle" aria-hidden="true"></i> {{'mydspace.new-submission' | translate}} + </a> + </div> + +</div> diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..40a955b349accfb3aa3b22e5072ff07cc7cf9399 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss @@ -0,0 +1,11 @@ +.parent { + display: flex; +} + +.upload { + flex: auto; +} + +.add { + flex: initial; +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..012f86f579f86b44c6db6c9c0a42aa31c58e11af --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -0,0 +1,101 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { Store } from '@ngrx/store'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; + +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +import { AuthService } from '../../core/auth/auth.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { createTestComponent } from '../../shared/testing/utils'; +import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission.component'; +import { AppState } from '../../app.reducer'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { SharedModule } from '../../shared/shared.module'; +import { getMockScrollToService } from '../../shared/mocks/mock-scroll-to-service'; +import { UploaderService } from '../../shared/uploader/uploader.service'; + +describe('MyDSpaceNewSubmissionComponent test', () => { + + const translateService: any = getMockTranslateService(); + const store: Store<AppState> = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + pipe: observableOf(true) + }); + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + SharedModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ + MyDSpaceNewSubmissionComponent, + TestComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: ScrollToService, useValue: getMockScrollToService() }, + { provide: Store, useValue: store }, + { provide: TranslateService, useValue: translateService }, + ChangeDetectorRef, + MyDSpaceNewSubmissionComponent, + UploaderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture<TestComponent>; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + <ds-my-dspace-new-submission (uploadEnd)="reload($event)"></ds-my-dspace-new-submission>`; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create MyDSpaceNewSubmissionComponent', inject([MyDSpaceNewSubmissionComponent], (app: MyDSpaceNewSubmissionComponent) => { + + expect(app).toBeDefined(); + + })); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + reload = (event) => { + return; + } +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..938a1ec899ee6a2835b3a83a5b20a3a83782e393 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -0,0 +1,118 @@ +import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; + +import { Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; + +import { SubmissionState } from '../../submission/submission.reducers'; +import { AuthService } from '../../core/auth/auth.service'; +import { MyDSpaceResult } from '../my-dspace-result.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { NotificationType } from '../../shared/notifications/models/notification-type'; +import { hasValue } from '../../shared/empty.util'; + +/** + * This component represents the whole mydspace page header + */ +@Component({ + selector: 'ds-my-dspace-new-submission', + styleUrls: ['./my-dspace-new-submission.component.scss'], + templateUrl: './my-dspace-new-submission.component.html' +}) +export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { + @Output() uploadEnd = new EventEmitter<Array<MyDSpaceResult<DSpaceObject>>>(); + + /** + * The UploaderOptions object + */ + public uploadFilesOptions: UploaderOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + + /** + * Subscription to unsubscribe from + */ + private sub: Subscription; + + /** + * Initialize instance variables + * + * @param {AuthService} authService + * @param {ChangeDetectorRef} changeDetectorRef + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {Store<SubmissionState>} store + * @param {TranslateService} translate + */ + constructor(private authService: AuthService, + private changeDetectorRef: ChangeDetectorRef, + private halService: HALEndpointService, + private notificationsService: NotificationsService, + private store: Store<SubmissionState>, + private translate: TranslateService) { + } + + /** + * Initialize url and Bearer token + */ + ngOnInit() { + this.sub = this.halService.getEndpoint('workspaceitems').pipe(first()).subscribe((url) => { + this.uploadFilesOptions.url = url; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + this.changeDetectorRef.detectChanges(); + } + ); + } + + /** + * Method called when file upload is completed to notify upload status + */ + public onCompleteItem(res) { + if (res && res._embedded && res._embedded.workspaceitems && res._embedded.workspaceitems.length > 0) { + const workspaceitems = res._embedded.workspaceitems; + this.uploadEnd.emit(workspaceitems); + + if (workspaceitems.length === 1) { + const options = new NotificationOptions(); + options.timeOut = 0; + const link = '/workspaceitems/' + workspaceitems[0].id + '/edit'; + this.notificationsService.notificationWithAnchor( + NotificationType.Success, + options, + link, + 'mydspace.general.text-here', + 'mydspace.upload.upload-successful', + 'here'); + } else if (workspaceitems.length > 1) { + this.notificationsService.success(null, this.translate.get('mydspace.upload.upload-multiple-successful', {qty: workspaceitems.length})); + } + + } else { + this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); + } + } + + /** + * Method called on file upload error + */ + public onUploadError() { + this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+my-dspace-page/my-dspace-page-routing.module.ts b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..d70a007e3ae2de9abf7c1bb4b5184b796d3ea035 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { MyDSpacePageComponent } from './my-dspace-page.component'; +import { MyDSpaceGuard } from './my-dspace.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: MyDSpacePageComponent, + data: { title: 'mydspace.title' }, + canActivate: [ + MyDSpaceGuard + ] + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the mydspace page path. + */ +export class MyDspacePageRoutingModule { +} diff --git a/src/app/+my-dspace-page/my-dspace-page.component.html b/src/app/+my-dspace-page/my-dspace-page.component.html new file mode 100644 index 0000000000000000000000000000000000000000..4c691028fc1af3365cec0474be9cdccbcd8fb285 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.html @@ -0,0 +1,48 @@ +<div class="container"> + <ds-my-dspace-new-submission *dsShowOnlyForRole="[roleTypeEnum.Submitter]" + (uploadEnd)="reload($event)"></ds-my-dspace-new-submission> + <div class="search-page row"> + <ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky" + id="search-sidebar" + [configurationList]="(configurationList$ | async)" + [resultCount]="(resultsRD$ | async)?.payload.totalElements" + [viewModeList]="viewModeList" + [inPlaceSearch]="inPlaceSearch"></ds-search-sidebar> + <div class="col-12 col-md-9"> + <ds-search-form id="search-form" + [query]="(searchOptions$ | async)?.query" + [scope]="(searchOptions$ | async)?.scope" + [currentUrl]="getSearchLink()" + [scopes]="(scopeListRD$ | async)" + [inPlaceSearch]="inPlaceSearch"> + </ds-search-form> + <ds-search-labels [inPlaceSearch]="inPlaceSearch"></ds-search-labels> + <div class="row"> + <div id="search-body" + class="row-offcanvas row-offcanvas-left" + [@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'"> + <ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12" + id="search-sidebar-sm" + [configurationList]="(configurationList$ | async)" + [resultCount]="(resultsRD$ | async)?.payload.totalElements" + (toggleSidebar)="closeSidebar()" + [ngClass]="{'active': !(isSidebarCollapsed() | async)}" + [inPlaceSearch]="inPlaceSearch"> + </ds-search-sidebar> + <div id="search-content" class="col-12"> + <div class="d-block d-md-none search-controls clearfix"> + <ds-view-mode-switch [viewModeList]="viewModeList" [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch> + <button (click)="openSidebar()" aria-controls="#search-body" + class="btn btn-outline-primary float-right open-sidebar"><i + class="fas fa-sliders"></i> {{"search.sidebar.open" + | translate}} + </button> + </div> + <ds-my-dspace-results [searchResults]="resultsRD$ | async" + [searchConfig]="searchOptions$ | async"></ds-my-dspace-results> + </div> + </div> + </div> + </div> + </div> +</div> diff --git a/src/app/+my-dspace-page/my-dspace-page.component.scss b/src/app/+my-dspace-page/my-dspace-page.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..86c589bf66c03127ab8d37207bfa39eadd5783bb --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.scss @@ -0,0 +1 @@ +@import '../+search-page/search-page.component.scss'; diff --git a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9658814a6aca4b1bd2d67617325fe352bc385f72 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts @@ -0,0 +1,204 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; + +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { HostWindowService } from '../shared/host-window.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { RemoteData } from '../core/data/remote-data'; +import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.component'; +import { RouteService } from '../shared/services/route.service'; +import { routeServiceStub } from '../shared/testing/route-service-stub'; +import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; +import { SearchService } from '../+search-page/search-service/search.service'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service'; +import { SearchFilterService } from '../+search-page/search-filters/search-filter/search-filter.service'; +import { RoleDirective } from '../shared/roles/role.directive'; +import { RoleService } from '../core/roles/role.service'; +import { MockRoleService } from '../shared/mocks/mock-role-service'; +import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; + +describe('MyDSpacePageComponent', () => { + let comp: MyDSpacePageComponent; + let fixture: ComponentFixture<MyDSpacePageComponent>; + let searchServiceObject: SearchService; + let searchConfigurationServiceObject: SearchConfigurationService; + const store: Store<MyDSpacePageComponent> = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + select: observableOf(true) + }); + const pagination: PaginationComponentOptions = new PaginationComponentOptions(); + pagination.id = 'mydspace-results-pagination'; + pagination.currentPage = 1; + pagination.pageSize = 10; + const sort: SortOptions = new SortOptions('score', SortDirection.DESC); + const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); + const searchServiceStub = jasmine.createSpyObj('SearchService', { + search: mockResults, + getSearchLink: '/mydspace', + getScopes: observableOf(['test-scope']), + setServiceOptions: {} + }); + const configurationParam = 'default'; + const queryParam = 'test query'; + const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; + const paginatedSearchOptions = new PaginatedSearchOptions({ + configuration: configurationParam, + query: queryParam, + scope: scopeParam, + pagination, + sort + }); + const activatedRouteStub = { + snapshot: { + queryParamMap: new Map([ + ['query', queryParam], + ['scope', scopeParam] + ]) + }, + queryParams: observableOf({ + query: queryParam, + scope: scopeParam + }) + }; + const sidebarService = { + isCollapsed: observableOf(true), + collapse: () => this.isCollapsed = observableOf(true), + expand: () => this.isCollapsed = observableOf(false) + }; + const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } + } as SearchFixedFilterService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()], + declarations: [MyDSpacePageComponent, RoleDirective], + providers: [ + { provide: SearchService, useValue: searchServiceStub }, + { + provide: CommunityDataService, + useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll']) + }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: RouteService, useValue: routeServiceStub }, + { + provide: Store, useValue: store + }, + { + provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', + { + isXs: observableOf(true), + isSm: observableOf(false), + isXsOrSm: observableOf(true) + }) + }, + { + provide: SearchSidebarService, + useValue: sidebarService + }, + { + provide: SearchFilterService, + useValue: {} + }, { + provide: SEARCH_CONFIG_SERVICE, + useValue: new SearchConfigurationServiceStub() + }, + { + provide: RoleService, + useValue: new MockRoleService() + }, + { + provide: SearchFixedFilterService, + useValue: mockFixedFilterService + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MyDSpacePageComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpacePageComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + fixture.detectChanges(); + searchServiceObject = (comp as any).service; + searchConfigurationServiceObject = (comp as any).searchConfigService; + }); + + afterEach(() => { + comp = null; + searchServiceObject = null; + searchConfigurationServiceObject = null; + }); + + it('should get the scope and query from the route parameters', () => { + + searchConfigurationServiceObject.paginatedSearchOptions.next(paginatedSearchOptions); + expect(comp.searchOptions$).toBeObservable(cold('b', { + b: paginatedSearchOptions + })); + + }); + + describe('when the open sidebar button is clicked in mobile view', () => { + + beforeEach(() => { + spyOn(comp, 'openSidebar'); + const openSidebarButton = fixture.debugElement.query(By.css('.open-sidebar')); + openSidebarButton.triggerEventHandler('click', null); + }); + + it('should trigger the openSidebar function', () => { + expect(comp.openSidebar).toHaveBeenCalled(); + }); + + }); + + describe('when sidebarCollapsed is true in mobile view', () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; + comp.isSidebarCollapsed = () => observableOf(true); + fixture.detectChanges(); + }); + + it('should close the sidebar', () => { + expect(menu.classList).not.toContain('active'); + }); + + }); + + describe('when sidebarCollapsed is false in mobile view', () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; + comp.isSidebarCollapsed = () => observableOf(false); + fixture.detectChanges(); + }); + + it('should open the menu', () => { + expect(menu.classList).toContain('active'); + }); + + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-page.component.ts b/src/app/+my-dspace-page/my-dspace-page.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..251bf50bd1c988884ab7d664e2bb3d8aadb1f597 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectionStrategy, + Component, + Inject, + InjectionToken, + Input, + OnInit +} from '@angular/core'; + +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { switchMap, tap, } from 'rxjs/operators'; + +import { PaginatedList } from '../core/data/paginated-list'; +import { RemoteData } from '../core/data/remote-data'; +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { pushInOut } from '../shared/animations/push'; +import { HostWindowService } from '../shared/host-window.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchService } from '../+search-page/search-service/search.service'; +import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service'; +import { hasValue } from '../shared/empty.util'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { MyDSpaceResult } from './my-dspace-result.model'; +import { MyDSpaceResponseParsingService } from '../core/data/mydspace-response-parsing.service'; +import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; +import { RoleType } from '../core/roles/role-types'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; +import { ViewMode } from '../core/shared/view-mode.model'; +import { MyDSpaceRequest } from '../core/data/request.models'; + +export const MYDSPACE_ROUTE = '/mydspace'; +export const SEARCH_CONFIG_SERVICE: InjectionToken<SearchConfigurationService> = new InjectionToken<SearchConfigurationService>('searchConfigurationService'); + +/** + * This component represents the whole mydspace page + */ +@Component({ + selector: 'ds-my-dspace-page', + styleUrls: ['./my-dspace-page.component.scss'], + templateUrl: './my-dspace-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: MyDSpaceConfigurationService + } + ] +}) +export class MyDSpacePageComponent implements OnInit { + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch = true; + + /** + * The list of available configuration options + */ + configurationList$: Observable<SearchConfigurationOption[]>; + + /** + * The current search results + */ + resultsRD$: BehaviorSubject<RemoteData<PaginatedList<MyDSpaceResult<DSpaceObject>>>> = new BehaviorSubject(null); + + /** + * The current paginated search options + */ + searchOptions$: Observable<PaginatedSearchOptions>; + + /** + * The current relevant scopes + */ + scopeListRD$: Observable<DSpaceObject[]>; + + /** + * Emits true if were on a small screen + */ + isXsOrSm$: Observable<boolean>; + + /** + * Subscription to unsubscribe from + */ + sub: Subscription; + + /** + * Variable for enumeration RoleType + */ + roleTypeEnum = RoleType; + + /** + * List of available view mode + */ + viewModeList = [ViewMode.List, ViewMode.Detail]; + + constructor(private service: SearchService, + private sidebarService: SearchSidebarService, + private windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: MyDSpaceConfigurationService) { + this.isXsOrSm$ = this.windowService.isXsOrSm(); + this.service.setServiceOptions(MyDSpaceResponseParsingService, MyDSpaceRequest); + } + + /** + * Initialize available configuration list + * + * Listening to changes in the paginated search options + * If something changes, update the search results + * + * Listen to changes in the scope + * If something changes, update the list of scopes for the dropdown + */ + ngOnInit(): void { + this.configurationList$ = this.searchConfigService.getAvailableConfigurationOptions(); + this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; + + this.sub = this.searchOptions$.pipe( + tap(() => this.resultsRD$.next(null)), + switchMap((options: PaginatedSearchOptions) => this.service.search(options).pipe(getSucceededRemoteData()))) + .subscribe((results) => { + this.resultsRD$.next(results); + }); + this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( + switchMap((scopeId) => this.service.getScopes(scopeId)) + ); + + } + + /** + * Set the sidebar to a collapsed state + */ + public closeSidebar(): void { + this.sidebarService.collapse() + } + + /** + * Set the sidebar to an expanded state + */ + public openSidebar(): void { + this.sidebarService.expand(); + } + + /** + * Check if the sidebar is collapsed + * @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded + */ + public isSidebarCollapsed(): Observable<boolean> { + return this.sidebarService.isCollapsed; + } + + /** + * @returns {string} The base path to the search page + */ + public getSearchLink(): string { + return this.service.getSearchLink(); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+my-dspace-page/my-dspace-page.module.ts b/src/app/+my-dspace-page/my-dspace-page.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b8cf37b7af4b192670e4ea46fa5282eb776bbb9 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.module.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; + +import { MyDspacePageRoutingModule } from './my-dspace-page-routing.module'; +import { MyDSpacePageComponent } from './my-dspace-page.component'; +import { SearchPageModule } from '../+search-page/search-page.module'; +import { MyDSpaceResultsComponent } from './my-dspace-results/my-dspace-results.component'; +import { WorkspaceitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component'; +import { ItemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component'; +import { WorkflowitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component'; +import { ClaimedMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component'; +import { PoolMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component'; +import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission/my-dspace-new-submission.component'; +import { ItemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component'; +import { WorkspaceitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component'; +import { WorkflowitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component'; +import { ClaimedMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component'; +import { PoolMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-lement.component'; +import { MyDSpaceGuard } from './my-dspace.guard'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + MyDspacePageRoutingModule, + SearchPageModule + ], + declarations: [ + MyDSpacePageComponent, + MyDSpaceResultsComponent, + ItemMyDSpaceResultListElementComponent, + WorkspaceitemMyDSpaceResultListElementComponent, + WorkflowitemMyDSpaceResultListElementComponent, + ClaimedMyDSpaceResultListElementComponent, + PoolMyDSpaceResultListElementComponent, + ItemMyDSpaceResultDetailElementComponent, + WorkspaceitemMyDSpaceResultDetailElementComponent, + WorkflowitemMyDSpaceResultDetailElementComponent, + ClaimedMyDSpaceResultDetailElementComponent, + PoolMyDSpaceResultDetailElementComponent, + MyDSpaceNewSubmissionComponent + ], + providers: [ + MyDSpaceGuard, + MyDSpaceConfigurationService + ], + entryComponents: [ + ItemMyDSpaceResultListElementComponent, + WorkspaceitemMyDSpaceResultListElementComponent, + WorkflowitemMyDSpaceResultListElementComponent, + ClaimedMyDSpaceResultListElementComponent, + PoolMyDSpaceResultListElementComponent, + ItemMyDSpaceResultDetailElementComponent, + WorkspaceitemMyDSpaceResultDetailElementComponent, + WorkflowitemMyDSpaceResultDetailElementComponent, + ClaimedMyDSpaceResultDetailElementComponent, + PoolMyDSpaceResultDetailElementComponent + ] +}) + +/** + * This module handles all components that are necessary for the mydspace page + */ +export class MyDSpacePageModule { + +} diff --git a/src/app/+my-dspace-page/my-dspace-result.model.ts b/src/app/+my-dspace-page/my-dspace-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..d300ed0bc8a804fd63c9a425a85e0de60818a68f --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-result.model.ts @@ -0,0 +1,19 @@ +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { MetadataMap } from '../core/shared/metadata.models'; +import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; + +/** + * Represents a search result object of a certain (<T>) DSpaceObject + */ +export class MyDSpaceResult<T extends DSpaceObject> implements ListableObject { + /** + * The DSpaceObject that was found + */ + indexableObject: T; + + /** + * The metadata that was used to find this item, hithighlighted + */ + hitHighlights: MetadataMap; + +} diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html new file mode 100644 index 0000000000000000000000000000000000000000..132a0d22040141c37952b2c8ad9937ff6977691c --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html @@ -0,0 +1,12 @@ +<div *ngIf="searchResults?.hasSucceeded && !searchResults?.isLoading && searchResults?.payload?.page.length > 0" @fadeIn> + <ds-viewable-collection + [config]="searchConfig.pagination" + [hasBorder]="hasBorder" + [sortConfig]="searchConfig.sort" + [objects]="searchResults" + [hideGear]="true"> + </ds-viewable-collection> +</div> +<ds-loading *ngIf="isLoading()" message="{{'loading.mydspace-results' | translate}}"></ds-loading> +<ds-error *ngIf="searchResults?.hasFailed && (!searchResults?.error || searchResults?.error?.statusCode != 400)" message="{{'error.search-results' | translate}}"></ds-error> +<h3 *ngIf="searchResults?.payload?.page.length == 0" class="text-center text-muted" ><span>{{'mydspace.results.no-results' | translate}}</span></h3> diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..67625706a6d586b5f654c6c4e767829a7a2d1167 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts @@ -0,0 +1,58 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { QueryParamsDirectiveStub } from '../../shared/testing/query-params-directive-stub'; +import { MyDSpaceResultsComponent } from './my-dspace-results.component'; + +describe('MyDSpaceResultsComponent', () => { + let comp: MyDSpaceResultsComponent; + let fixture: ComponentFixture<MyDSpaceResultsComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule], + declarations: [ + MyDSpaceResultsComponent, + QueryParamsDirectiveStub], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpaceResultsComponent); + comp = fixture.componentInstance; // MyDSpaceResultsComponent test instance + }); + + it('should display results when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).not.toBeNull(); + }); + + it('should not display link when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('a'))).toBeNull(); + }); + + it('should display error message if error is != 400', () => { + (comp as any).searchResults = { hasFailed: true, error: { statusCode: 500 } }; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-error'))).not.toBeNull(); + }); + + it('should display a message if search result is empty', () => { + (comp as any).searchResults = { payload: { page: { length: 0 } } }; + (comp as any).searchConfig = { query: 'foobar' }; + fixture.detectChanges(); + + const linkDes = fixture.debugElement.queryAll(By.css('text-muted')); + + expect(linkDes).toBeDefined() + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a16def9c19232ed03af5041948813e1614374a9 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts @@ -0,0 +1,51 @@ +import { Component, Input } from '@angular/core'; + +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { MyDSpaceResult } from '../my-dspace-result.model'; +import { SearchOptions } from '../../+search-page/search-options.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { ViewMode } from '../../core/shared/view-mode.model'; +import { isEmpty } from '../../shared/empty.util'; + +/** + * Component that represents all results for mydspace page + */ +@Component({ + selector: 'ds-my-dspace-results', + templateUrl: './my-dspace-results.component.html', + animations: [ + fadeIn, + fadeInOut + ] +}) +export class MyDSpaceResultsComponent { + + /** + * The actual search result objects + */ + @Input() searchResults: RemoteData<PaginatedList<MyDSpaceResult<DSpaceObject>>>; + + /** + * The current configuration of the search + */ + @Input() searchConfig: SearchOptions; + + /** + * The current view mode for the search results + */ + @Input() viewMode: ViewMode; + + /** + * A boolean representing if search results entry are separated by a line + */ + hasBorder = true; + + /** + * Check if mydspace search results are loading + */ + isLoading() { + return !this.searchResults || isEmpty(this.searchResults) || this.searchResults.isLoading; + } +} diff --git a/src/app/+my-dspace-page/my-dspace.guard.ts b/src/app/+my-dspace-page/my-dspace.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..9cb9aff485d7e39405d24fde0c7352e8376db85e --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace.guard.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, NavigationExtras, Router, RouterStateSnapshot } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; +import { isEmpty } from '../shared/empty.util'; +import { MYDSPACE_ROUTE } from './my-dspace-page.component'; +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; + +/** + * Prevent unauthorized activating and loading of mydspace configuration + * @class MyDSpaceGuard + */ +@Injectable() +export class MyDSpaceGuard implements CanActivate { + + /** + * @constructor + */ + constructor(private configurationService: MyDSpaceConfigurationService, private router: Router) { + } + + /** + * True when configuration is valid + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { + return this.configurationService.getAvailableConfigurationTypes().pipe( + first(), + map((configurationList) => this.validateConfigurationParam(route.queryParamMap.get('configuration'), configurationList))); + } + + /** + * Check if the given configuration is present in the list of those available + * + * @param configuration + * the configuration to validate + * @param configurationList + * the list of available configuration + * + */ + private validateConfigurationParam(configuration: string, configurationList: MyDSpaceConfigurationValueType[]): boolean { + const configurationDefault: string = configurationList[0]; + if (isEmpty(configuration) || !configurationList.includes(configuration as MyDSpaceConfigurationValueType)) { + // If configuration param is empty or is not included in available configurations redirect to a default configuration value + const navigationExtras: NavigationExtras = { + queryParams: {configuration: configurationDefault} + }; + + this.router.navigate([MYDSPACE_ROUTE], navigationExtras); + return false; + } else { + return true; + } + } +} diff --git a/src/app/+search-page/filtered-search-page.component.spec.ts b/src/app/+search-page/filtered-search-page.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c49767ed2143dbeeb66a98fc18821496752ff97 --- /dev/null +++ b/src/app/+search-page/filtered-search-page.component.spec.ts @@ -0,0 +1,37 @@ +import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { configureSearchComponentTestingModule } from './search-page.component.spec'; +import { SearchConfigurationService } from './search-service/search-configuration.service'; + +describe('FilteredSearchPageComponent', () => { + let comp: FilteredSearchPageComponent; + let fixture: ComponentFixture<FilteredSearchPageComponent>; + let searchConfigService: SearchConfigurationService; + + beforeEach(async(() => { + configureSearchComponentTestingModule(FilteredSearchPageComponent); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FilteredSearchPageComponent); + comp = fixture.componentInstance; + searchConfigService = (comp as any).searchConfigService; + fixture.detectChanges(); + }); + + describe('when fixedFilterQuery is defined', () => { + const fixedFilterQuery = 'fixedFilterQuery'; + + beforeEach(() => { + spyOn(searchConfigService, 'updateFixedFilter').and.callThrough(); + comp.fixedFilterQuery = fixedFilterQuery; + comp.ngOnInit(); + fixture.detectChanges(); + }); + + it('should update the paginated search options', () => { + expect(searchConfigService.updateFixedFilter).toHaveBeenCalledWith(fixedFilterQuery); + }); + }); + +}); diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..d577c2c44c542eeb07bc8a2918a2f3f81416fe5d --- /dev/null +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -0,0 +1,58 @@ +import { HostWindowService } from '../shared/host-window.service'; +import { SearchService } from './search-service/search.service'; +import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; +import { SearchPageComponent } from './search-page.component'; +import { ChangeDetectionStrategy, Component, Inject, Input } from '@angular/core'; +import { pushInOut } from '../shared/animations/push'; +import { RouteService } from '../shared/services/route.service'; +import { SearchConfigurationService } from './search-service/search-configuration.service'; +import { Observable } from 'rxjs'; +import { PaginatedSearchOptions } from './paginated-search-options.model'; +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; + +/** + * This component renders a simple item page. + * The route parameter 'id' is used to request the item it represents. + * All fields of the item that should be displayed, are defined in its template. + */ +@Component({selector: 'ds-filtered-search-page', + styleUrls: ['./search-page.component.scss'], + templateUrl: './search-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +export class FilteredSearchPageComponent extends SearchPageComponent { + + /** + * The actual query for the fixed filter. + * If empty, the query will be determined by the route parameter called 'filter' + */ + @Input() fixedFilterQuery: string; + + constructor(protected service: SearchService, + protected sidebarService: SearchSidebarService, + protected windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + protected routeService: RouteService) { + super(service, sidebarService, windowService, searchConfigService, routeService); + } + + /** + * Get the current paginated search options after updating the fixed filter using the fixedFilterQuery input + * This is to make sure the fixed filter is included in the paginated search options, as it is not part of any + * query or route parameters + * @returns {Observable<PaginatedSearchOptions>} + */ + protected getSearchOptions(): Observable<PaginatedSearchOptions> { + this.searchConfigService.updateFixedFilter(this.fixedFilterQuery); + return this.searchConfigService.paginatedSearchOptions; + } + +} diff --git a/src/app/+search-page/filtered-search-page.guard.ts b/src/app/+search-page/filtered-search-page.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..39fbb48c67791149ca422bdd085ca26a049599b9 --- /dev/null +++ b/src/app/+search-page/filtered-search-page.guard.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; + +@Injectable() +/** + * Assemble the correct i18n key for the filtered search page's title depending on the current route's filter parameter + * and title data. + * The format of the key will be "{title}{filter}.title" with: + * - title: The prefix of the key stored in route.data + * - filter: The current filter stored in route.params + */ +export class FilteredSearchPageGuard implements CanActivate { + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean { + const filter = route.params.filter; + + const newTitle = route.data.title + filter + '.title'; + + route.data = { title: newTitle }; + return true; + } +} diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts index 46f14c042d3da175f66f1147df33524a5bad977e..32f3217b54b66ef397587c35f8c3d34e94d880ae 100644 --- a/src/app/+search-page/normalized-search-result.model.ts +++ b/src/app/+search-page/normalized-search-result.model.ts @@ -1,4 +1,4 @@ -import { autoserialize } from 'cerialize'; +import { autoserialize, autoserializeAs } from 'cerialize'; import { MetadataMap } from '../core/shared/metadata.models'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; @@ -10,7 +10,7 @@ export class NormalizedSearchResult implements ListableObject { * The UUID of the DSpaceObject that was found */ @autoserialize - dspaceObject: string; + indexableObject: string; /** * The metadata that was used to find this item, hithighlighted diff --git a/src/app/+search-page/paginated-search-options.model.ts b/src/app/+search-page/paginated-search-options.model.ts index 8f4d93b0df1f5ffc4d153768b269aa31106f2276..45cd0b8f09f72d28e8cd0928a0e6567cac18c38e 100644 --- a/src/app/+search-page/paginated-search-options.model.ts +++ b/src/app/+search-page/paginated-search-options.model.ts @@ -12,7 +12,7 @@ export class PaginatedSearchOptions extends SearchOptions { pagination?: PaginationComponentOptions; sort?: SortOptions; - constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], pagination?: PaginationComponentOptions, sort?: SortOptions}) { + constructor(options: {configuration?: string, scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions}) { super(options); this.pagination = options.pagination; this.sort = options.sort; diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html new file mode 100644 index 0000000000000000000000000000000000000000..76cdc6c8f57f8c789ce3344c4c2ce820480982f9 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html @@ -0,0 +1,27 @@ +<div> + <div class="filters py-2"> + <ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-selected-option> + <ng-container *ngFor="let page of (filterValues$ | async)?.payload"> + <div [@facetLoad]="animationState"> + <ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-option> + </div> + </ng-container> + <div class="clearfix toggle-more-filters"> + <a class="float-left" *ngIf="!(isLastPage$ | async)" + (click)="showMore()">{{"search.filters.filter.show-more" + | translate}}</a> + <a class="float-right" *ngIf="(currentPage | async) > 1" + (click)="showFirstPageOnly()">{{"search.filters.filter.show-less" + | translate}}</a> + </div> + </div> + <ds-input-suggestions [suggestions]="(filterSearchResults | async)" + [placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" + [action]="getCurrentUrl()" + [name]="filterConfig.paramName" + [(ngModel)]="filter" + (submitSuggestion)="onSubmit($event)" + (clickSuggestion)="onSubmit($event)" + (findSuggestions)="findSuggestions($event)" + ngDefaultControl></ds-input-suggestions> +</div> diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..33e354f2d8a6b3f6ff4c841465428b9b7653e47f --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss @@ -0,0 +1,23 @@ +@import '../../../../../styles/variables.scss'; +@import '../../../../../styles/mixins.scss'; + +.filters { + a { + color: $body-color; + &:hover, &focus { + text-decoration: none; + } + span.badge { + vertical-align: text-top; + } + } + .toggle-more-filters a { + color: $link-color; + text-decoration: underline; + cursor: pointer; + } +} +::ng-deep em { + font-weight: bold; + font-style: normal; +} diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..83131e1344bd3883b60e45aa8468927677aa0fc0 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts @@ -0,0 +1,36 @@ +import { Component, OnInit } from '@angular/core'; + +import { FilterType } from '../../../search-service/filter-type.model'; +import { facetLoad, SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; +import { renderFacetFor } from '../search-filter-type-decorator'; +import { FacetValue } from '../../../search-service/facet-value.model'; + +@Component({ + selector: 'ds-search-authority-filter', + styleUrls: ['./search-authority-filter.component.scss'], + templateUrl: './search-authority-filter.component.html', + animations: [facetLoad] +}) + +/** + * Component that represents an authority facet for a specific filter configuration + */ +@renderFacetFor(FilterType.authority) +export class SearchAuthorityFilterComponent extends SearchFacetFilterComponent implements OnInit { + + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Retrieve facet value from search link + */ + protected getFacetValue(facet: FacetValue): string { + const search = facet.search; + const hashes = search.slice(search.indexOf('?') + 1).split('&'); + const params = {}; + hashes.map((hash) => { + const [key, val] = hash.split('='); + params[key] = decodeURIComponent(val) + }); + + return params[this.filterConfig.paramName]; + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html index 968bf9e42098070f28f6bddddd24abb6adfa2d47..cc39b80db833efdeaf9740e47d958a1324474865 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,9 +1,9 @@ <div> <div class="filters py-2"> - <ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option> + <ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-selected-option> <ng-container *ngFor="let page of (filterValues$ | async)?.payload"> <div [@facetLoad]="animationState"> - <ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option> + <ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-option> </div> </ng-container> <div class="clearfix toggle-more-filters"> 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 index f1dbedfb40f5878bdeee99f4cbb88b7e94dd1ae3..245c0e3ddb1da1370df4c8a8f626c667323d0381 100644 --- 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 @@ -19,10 +19,12 @@ import { By } from '@angular/platform-browser'; describe('SearchFacetOptionComponent', () => { let comp: SearchFacetOptionComponent; let fixture: ComponentFixture<SearchFacetOptionComponent>; - const filterName1 = 'test name'; + const filterName1 = 'testname'; + const filterName2 = 'testAuthorityname'; const value1 = 'testvalue1'; const value2 = 'test2'; - const value3 = 'another value3'; + const operator = 'authority'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { name: filterName1, type: FilterType.range, @@ -32,14 +34,38 @@ describe('SearchFacetOptionComponent', () => { minValue: 200, maxValue: 3000, }); + + const mockAuthorityFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName2, + type: FilterType.authority, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2 + }); + const value: FacetValue = { - value: value2, - count: 20, - search: '' - }; + label: value2, + value: value2, + count: 20, + search: `` + }; + + const selectedValue: FacetValue = { + label: value1, + value: value1, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1},${operator}` + }; + + const authorityValue: FacetValue = { + label: value2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + }; const searchLink = '/search'; - const selectedValues = [value1]; + const selectedValues = [selectedValue]; const selectedValues$ = observableOf(selectedValues); let filterService; let searchService; @@ -90,7 +116,7 @@ describe('SearchFacetOptionComponent', () => { fixture.detectChanges(); }); - describe('when the updateAddParams method is called wih a value', () => { + describe('when the updateAddParams method is called with a value', () => { it('should update the addQueryParams with the new parameter values', () => { comp.addQueryParams = {}; (comp as any).updateAddParams(selectedValues); @@ -101,6 +127,21 @@ describe('SearchFacetOptionComponent', () => { }); }); + describe('when filter type is authority and the updateAddParams method is called with a value', () => { + it('should update the addQueryParams with the new parameter values', () => { + comp.filterValue = authorityValue; + comp.filterConfig = mockAuthorityFilterConfig; + fixture.detectChanges(); + + comp.addQueryParams = {}; + (comp as any).updateAddParams(selectedValues); + expect(comp.addQueryParams).toEqual({ + [mockAuthorityFilterConfig.paramName]: [value1, `${value2},${operator}`], + page: 1 + }); + }); + }); + describe('when isVisible emits true', () => { it('the facet option should be visible', () => { comp.isVisible = observableOf(true); 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 index 016ebf62a372fda6496720ff8440bd50a395dec1..1fccee3736276e42762798f2855fd68bdc8b3c01 100644 --- 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 @@ -1,5 +1,5 @@ import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { map, take } from 'rxjs/operators'; +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'; @@ -8,6 +8,7 @@ import { SearchService } from '../../../../search-service/search.service'; import { SearchFilterService } from '../../search-filter.service'; import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; import { hasValue } from '../../../../../shared/empty.util'; +import { FilterType } from '../../../../search-service/filter-type.model'; @Component({ selector: 'ds-search-facet-option', @@ -32,7 +33,12 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { /** * Emits the active values for this filter */ - @Input() selectedValues$: Observable<string[]>; + @Input() selectedValues$: Observable<FacetValue[]>; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; /** * Emits true when this option should be visible and false when it should be invisible @@ -71,13 +77,16 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { * Checks if a value for this filter is currently active */ private isChecked(): Observable<boolean> { - return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value); + return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.getFacetValue()); } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } @@ -85,13 +94,33 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { * 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 { + private updateAddParams(selectedValues: FacetValue[]): void { this.addQueryParams = { - [this.filterConfig.paramName]: [...selectedValues, this.filterValue.value], + [this.filterConfig.paramName]: [...selectedValues.map((facetValue: FacetValue) => facetValue.label), this.getFacetValue()], page: 1 }; } + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Retrieve facet value related to facet type + */ + private getFacetValue(): string { + if (this.filterConfig.type === FilterType.authority) { + const search = this.filterValue.search; + const hashes = search.slice(search.indexOf('?') + 1).split('&'); + const params = {}; + hashes.map((hash) => { + const [key, val] = hash.split('='); + params[key] = decodeURIComponent(val) + }); + + return params[this.filterConfig.paramName]; + } else { + return this.filterValue.value; + } + } + /** * Make sure the subscription is unsubscribed from when this component is destroyed */ 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 index b485fe0fd081156ea33a6c51ddc6ee3854c7bdb4..8e8ad9b4e3ea4e0b7df2faf3b06c442b5b97b014 100644 --- 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 @@ -1,8 +1,8 @@ <a *ngIf="isVisible | async" class="d-flex flex-row" [routerLink]="[getSearchLink()]" [queryParams]="changeQueryParams" queryParamsHandling="merge"> - <span class="filter-value px-1">{{filterValue.value}}</span> + <span class="filter-value px-1">{{filterValue.label}}</span> <span class="float-right filter-value-count ml-auto"> <span class="badge badge-secondary badge-pill">{{filterValue.count}}</span> </span> -</a> \ No newline at end of file +</a> 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 index 218730263bbf125942ad254986c152049d6c76c3..d3264214edb6c589ea63dc794425350ab421c058 100644 --- 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 @@ -35,10 +35,11 @@ describe('SearchFacetRangeOptionComponent', () => { maxValue: 3000, }); const value: FacetValue = { - value: value2, - count: 20, - search: '' - }; + label: value2, + value: value2, + count: 20, + search: '' + }; const searchLink = '/search'; let filterService; @@ -92,10 +93,11 @@ describe('SearchFacetRangeOptionComponent', () => { it('should update the changeQueryParams with the new parameter values', () => { comp.changeQueryParams = {}; comp.filterValue = { - value: '50-60', - count: 20, - search: '' - }; + label: '50-60', + value: '50-60', + count: 20, + search: '' + }; (comp as any).updateChangeParams(); expect(comp.changeQueryParams).toEqual({ [mockFilterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: ['50'], 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 index 67d31293b0e1bbbca05e91ec588b0665d66306b6..54d5d535df8905e2adff3f4fa795e6e24945c022 100644 --- 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 @@ -35,6 +35,11 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { */ @Input() filterConfig: SearchFilterConfig; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Emits true when this option should be visible and false when it should be invisible */ @@ -75,9 +80,12 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } 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 index ba43bae100c56aab0ded8947e4a6fdeb19248383..5657bd224e11d9fb5603d0e4000719510049545f 100644 --- 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 @@ -2,5 +2,5 @@ [routerLink]="[getSearchLink()]" [queryParams]="removeQueryParams" queryParamsHandling="merge"> <input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/> - <span class="filter-value pl-1">{{selectedValue}}</span> -</a> \ No newline at end of file + <span class="filter-value pl-1 text-capitalize">{{selectedValue.label}}</span> +</a> 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 index 545ba1d66bf2b94051b16c78e62803d42ac6a2fb..01defb9893b3a6cd7c0a2f79d2848ee1fc3f55b5 100644 --- 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 @@ -13,13 +13,18 @@ import { RouterStub } from '../../../../../shared/testing/router-stub'; import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; import { SearchFilterService } from '../../search-filter.service'; import { SearchFacetSelectedOptionComponent } from './search-facet-selected-option.component'; +import { FacetValue } from '../../../../search-service/facet-value.model'; describe('SearchFacetSelectedOptionComponent', () => { let comp: SearchFacetSelectedOptionComponent; let fixture: ComponentFixture<SearchFacetSelectedOptionComponent>; const filterName1 = 'test name'; + const filterName2 = 'testAuthorityname'; + const label1 = 'test value 1'; const value1 = 'testvalue1'; + const label2 = 'test 2'; const value2 = 'test2'; + const operator = 'authority'; const mockFilterConfig = Object.assign(new SearchFilterConfig(), { name: filterName1, type: FilterType.range, @@ -29,10 +34,55 @@ describe('SearchFacetSelectedOptionComponent', () => { minValue: 200, maxValue: 3000, }); + const mockAuthorityFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName2, + type: FilterType.authority, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2 + }); const searchLink = '/search'; - const selectedValues = [value1, value2]; + const selectedValue: FacetValue = { + label: value1, + value: value1, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1}` + }; + const selectedValue2: FacetValue = { + label: value2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value2}` + }; + const selectedAuthorityValue: FacetValue = { + label: label1, + value: value1, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value1},${operator}` + }; + const selectedAuthorityValue2: FacetValue = { + label: label2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + }; + const selectedValues = [selectedValue, selectedValue2]; + const selectedAuthorityValues = [selectedAuthorityValue, selectedAuthorityValue2]; + const facetValue = { + label: value2, + value: value2, + count: 1, + search: '' + }; + const authorityValue: FacetValue = { + label: label2, + value: value2, + count: 20, + search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + }; const selectedValues$ = observableOf(selectedValues); + const selectedAuthorityValues$ = observableOf(selectedAuthorityValues); let filterService; let searchService; let router; @@ -76,7 +126,7 @@ describe('SearchFacetSelectedOptionComponent', () => { filterService = (comp as any).filterService; searchService = (comp as any).searchService; router = (comp as any).router; - comp.selectedValue = value2; + comp.selectedValue = facetValue; comp.selectedValues$ = selectedValues$; comp.filterConfig = mockFilterConfig; fixture.detectChanges(); @@ -92,4 +142,20 @@ describe('SearchFacetSelectedOptionComponent', () => { }); }); }); + + describe('when filter type is authority and the updateRemoveParams method is called with a value', () => { + it('should update the removeQueryParams with the new parameter values', () => { + spyOn(filterService, 'getSelectedValuesForFilter').and.returnValue(selectedAuthorityValues); + comp.selectedValue = authorityValue; + comp.selectedValues$ = selectedAuthorityValues$; + comp.filterConfig = mockAuthorityFilterConfig; + comp.removeQueryParams = {}; + fixture.detectChanges(); + (comp as any).updateRemoveParams(selectedAuthorityValues); + expect(comp.removeQueryParams).toEqual({ + [mockAuthorityFilterConfig.paramName]: [`${value1},${operator}`], + page: 1 + }); + }); + }); }); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts index 23ad3eccba43e57f7be1fb8164352851250a487b..78dde92c2b486eed484f93a77bf1d894df32b9cc 100644 --- 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 @@ -6,6 +6,8 @@ import { SearchService } from '../../../../search-service/search.service'; import { SearchFilterService } from '../../search-filter.service'; import { hasValue } from '../../../../../shared/empty.util'; import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; @Component({ selector: 'ds-search-facet-selected-option', @@ -20,7 +22,7 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { /** * The value for this component */ - @Input() selectedValue: string; + @Input() selectedValue: FacetValue; /** * The filter configuration for this facet option @@ -30,7 +32,12 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { /** * Emits the active values for this filter */ - @Input() selectedValues$: Observable<string[]>; + @Input() selectedValues$: Observable<FacetValue[]>; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; /** * UI parameters when this filter is removed @@ -60,9 +67,12 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } @@ -70,13 +80,35 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { * 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 { + private updateRemoveParams(selectedValues: FacetValue[]): void { this.removeQueryParams = { - [this.filterConfig.paramName]: selectedValues.filter((v) => v !== this.selectedValue), + [this.filterConfig.paramName]: selectedValues + .filter((facetValue: FacetValue) => facetValue.label !== this.selectedValue.label) + .map((facetValue: FacetValue) => this.getFacetValue(facetValue)), page: 1 }; } + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Retrieve facet value related to facet type + */ + private getFacetValue(facetValue: FacetValue): string { + if (this.filterConfig.type === FilterType.authority) { + const search = facetValue.search; + const hashes = search.slice(search.indexOf('?') + 1).split('&'); + const params = {}; + hashes.map((hash) => { + const [key, val] = hash.split('='); + params[key] = decodeURIComponent(val) + }); + + return params[this.filterConfig.paramName]; + } else { + return facetValue.value; + } + } + /** * Make sure the subscription is unsubscribed from when this component is destroyed */ 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 6369a7691edacdfb7b8f1bbadc4d8930bf317bd5..6720b306813c6e9f867e642445680affff012d0d 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts @@ -2,7 +2,7 @@ import { Component, Injector, Input, OnInit } from '@angular/core'; import { renderFilterType } from '../search-filter-type-decorator'; import { FilterType } from '../../../search-service/filter-type.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; -import { FILTER_CONFIG } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH } from '../search-filter.service'; import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; @@ -20,6 +20,11 @@ export class SearchFacetFilterWrapperComponent implements OnInit { */ @Input() filterConfig: SearchFilterConfig; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * The constructor of the search facet filter that should be rendered, based on the filter config's type */ @@ -39,7 +44,8 @@ export class SearchFacetFilterWrapperComponent implements OnInit { this.searchFilter = this.getSearchFilter(); this.objectInjector = Injector.create({ providers: [ - { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] } + { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }, + { provide: IN_PLACE_SEARCH, useFactory: () => (this.inPlaceSearch), deps: [] } ], parent: this.injector }); 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 cb3d4730b4f17516469064c0f1330b51271a0719..5d8b51de960e02ce773a603357f8ea475075da51 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { FilterType } from '../../../search-service/filter-type.model'; import { FacetValue } from '../../../search-service/facet-value.model'; @@ -17,7 +17,9 @@ import { Router } from '@angular/router'; import { PageInfo } from '../../../../core/shared/page-info.model'; import { SearchFacetFilterComponent } from './search-facet-filter.component'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; -import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { tap } from 'rxjs/operators'; describe('SearchFacetFilterComponent', () => { let comp: SearchFacetFilterComponent; @@ -35,14 +37,17 @@ describe('SearchFacetFilterComponent', () => { }); const values: FacetValue[] = [ { + label: value1, value: value1, count: 52, search: '' }, { + label: value2, value: value2, count: 20, search: '' }, { + label: value3, value: value3, count: 5, search: '' @@ -65,8 +70,9 @@ describe('SearchFacetFilterComponent', () => { { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, { provide: Router, useValue: new RouterStub() }, { provide: FILTER_CONFIG, useValue: new SearchFilterConfig() }, - { provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} }, - { provide: SearchConfigurationService, useValue: {searchOptions: observableOf({})} }, + { provide: RemoteDataBuildService, useValue: { aggregate: () => observableOf({}) } }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + { provide: IN_PLACE_SEARCH, useValue: false }, { provide: SearchFilterService, useValue: { getSelectedValuesForFilter: () => observableOf(selectedValues), @@ -168,13 +174,20 @@ describe('SearchFacetFilterComponent', () => { const searchUrl = '/search/path'; const testValue = 'test'; const data = testValue; + beforeEach(() => { + comp.selectedValues$ = observableOf(selectedValues.map((value) => + Object.assign(new FacetValue(), { + label: value, + value: value + }))); + fixture.detectChanges(); spyOn(comp, 'getSearchLink').and.returnValue(searchUrl); comp.onSubmit(data); }); it('should call navigate on the router with the right searchlink and parameters', () => { - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { + expect(router.navigate).toHaveBeenCalledWith(searchUrl.split('/'), { queryParams: { [mockFilterConfig.paramName]: [...selectedValues, testValue] }, queryParamsHandling: 'merge' }); @@ -188,9 +201,9 @@ describe('SearchFacetFilterComponent', () => { }); it('should call showFirstPageOnly and empty the filter', () => { - expect(comp.animationState).toEqual('loading'); - expect((comp as any).collapseNextUpdate).toBeTruthy(); - expect(comp.filter).toEqual(''); + expect(comp.animationState).toEqual('loading'); + expect((comp as any).collapseNextUpdate).toBeTruthy(); + expect(comp.filter).toEqual(''); }); }); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index 367947a37775c66d569eaa49df5cce862e470444..772240eb0b597f90f507ffdadd78c35ff36a6df2 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -6,7 +6,7 @@ import { Subject, Subscription } from 'rxjs'; -import { switchMap, distinctUntilChanged, map, take } from 'rxjs/operators'; +import { switchMap, distinctUntilChanged, map, take, flatMap, tap } from 'rxjs/operators'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; @@ -18,11 +18,12 @@ import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe'; import { FacetValue } from '../../../search-service/facet-value.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { SearchService } from '../../../search-service/search.service'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { SearchOptions } from '../../../search-options.model'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-facet-filter', @@ -56,7 +57,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * List of subscriptions to unsubscribe from */ - private subs: Subscription[] = []; + protected subs: Subscription[] = []; /** * Emits the result values for this filter found by the current filter query @@ -66,8 +67,8 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * Emits the active values for this filter */ - selectedValues$: Observable<string[]>; - private collapseNextUpdate = true; + selectedValues$: Observable<FacetValue[]>; + protected collapseNextUpdate = true; /** * State of the requested facets used to time the animation @@ -81,9 +82,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { constructor(protected searchService: SearchService, protected filterService: SearchFilterService, - protected searchConfigService: SearchConfigurationService, protected rdbs: RemoteDataBuildService, protected router: Router, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + @Inject(IN_PLACE_SEARCH) public inPlaceSearch: boolean, @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig) { } @@ -94,10 +96,9 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined)); this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged()); - 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( + const facetValues$ = observableCombineLatest(this.searchOptions$, this.currentPage).pipe( map(([options, page]) => { return { options, page } }), @@ -115,8 +116,9 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { ) }) ); + let filterValues = []; - this.subs.push(facetValues.subscribe((facetOutcome) => { + this.subs.push(facetValues$.subscribe((facetOutcome) => { const newValues$ = facetOutcome.values; if (this.collapseNextUpdate) { @@ -130,9 +132,24 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { filterValues = [...filterValues, newValues$]; - this.subs.push(this.rdbs.aggregate(filterValues).subscribe((rd: RemoteData<Array<PaginatedList<FacetValue>>>) => { + this.subs.push(this.rdbs.aggregate(filterValues).pipe( + tap((rd: RemoteData<Array<PaginatedList<FacetValue>>>) => { + this.selectedValues$ = this.filterService.getSelectedValuesForFilter(this.filterConfig).pipe( + map((selectedValues) => { + return selectedValues.map((value: string) => { + const fValue = [].concat(...rd.payload.map((page) => page.page)).find((facetValue: FacetValue) => facetValue.value === value); + if (hasValue(fValue)) { + return fValue; + } + return Object.assign(new FacetValue(), { label: value, value: value }); + }); + }) + ); + }) + ).subscribe((rd: RemoteData<Array<PaginatedList<FacetValue>>>) => { this.animationState = 'ready'; this.filterValues$.next(rd); + })); this.subs.push(newValues$.pipe(take(1)).subscribe((rd) => { this.isLastPage$.next(hasNoValue(rd.payload.next)) @@ -158,12 +175,25 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } + /** + * @returns {string[]} The base path to the search page, or the current page when inPlaceSearch is true, split in separate pieces + */ + public getSearchLinkParts(): string[] { + if (this.inPlaceSearch) { + return []; + } + return this.getSearchLink().split('/'); + } + /** * Show the next page as well */ @@ -199,9 +229,14 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { onSubmit(data: any) { this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { if (isNotEmpty(data)) { - this.router.navigate([this.getSearchLink()], { + this.router.navigate(this.getSearchLinkParts(), { queryParams: - { [this.filterConfig.paramName]: [...selectedValues, data] }, + { + [this.filterConfig.paramName]: [ + ...selectedValues.map((facet) => this.getFacetValue(facet)), + data + ] + }, queryParamsHandling: 'merge' }); this.filter = ''; @@ -252,7 +287,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { return rd.payload.page.map((facet) => { return { displayValue: this.getDisplayValue(facet, data), - value: facet.value + value: this.getFacetValue(facet) } }) } @@ -264,6 +299,13 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { } } + /** + * Retrieve facet value + */ + protected getFacetValue(facet: FacetValue): string { + return facet.value; + } + /** * Transforms the facet value string, so if the query matches part of the value, it's emphasized in the value * @param {FacetValue} facet The value of the facet as returned by the server 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 5c4db44d245155203a3f85c1b760663b93887d9b..a1758d73393401be2febb0f980b1ed0a2cb639c5 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 @@ -2,6 +2,6 @@ <div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fas float-right" [ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'"></span></div> <div [@slide]="(collapsed$ | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : closed}"> - <ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper> + <ds-search-facet-filter-wrapper [filterConfig]="filter" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-filter-wrapper> </div> -</div> \ No newline at end of file +</div> 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 30ef349675a88a8880708977ffee2db9166661cf..23c4ab3b53b6cd0e74240462094a6e6f60383868 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 @@ -11,6 +11,8 @@ import { SearchFilterComponent } from './search-filter.component'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { FilterType } from '../../search-service/filter-type.model'; import { SearchConfigurationService } from '../../search-service/search-configuration.service'; +import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service-stub'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -54,8 +56,6 @@ describe('SearchFilterComponent', () => { getFacetValuesFor: (filter) => mockResults }; - const searchConfigServiceStub = {}; - beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], @@ -66,7 +66,7 @@ describe('SearchFilterComponent', () => { provide: SearchFilterService, useValue: mockFilterService }, - { provide: SearchConfigurationService, useValue: searchConfigServiceStub }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchFilterComponent, { 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 14ba8f0b76c0ed934e274674172c1b9c8405c73e..bfe9f3be63406e4ed9724ec0cedc1212aa3423ea 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,12 +1,15 @@ +import { Component, Inject, Input, OnInit } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; import { filter, first, map, startWith, switchMap, take } from 'rxjs/operators'; -import { Component, Input, OnInit } from '@angular/core'; + import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterService } from './search-filter.service'; -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'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-filter', @@ -24,6 +27,11 @@ export class SearchFilterComponent implements OnInit { */ @Input() filter: SearchFilterConfig; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * True when the filter is 100% collapsed in the UI */ @@ -44,7 +52,10 @@ export class SearchFilterComponent implements OnInit { */ active$: Observable<boolean>; - constructor(private filterService: SearchFilterService, private searchService: SearchService, private searchConfigService: SearchConfigurationService) { + constructor( + private filterService: SearchFilterService, + private searchService: SearchService, + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { } /** 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 187bcd50d050878142c2112b2108702895d68674..7102c8c9bc667ae06ad3ce543ef98e7b9a29d40e 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,9 +1,4 @@ -import { - SearchFilterAction, - SearchFilterActionTypes, - SearchFilterInitializeAction -} from './search-filter.actions'; -import { isEmpty, isNotUndefined } from '../../../shared/empty.util'; +import { SearchFilterAction, SearchFilterActionTypes, SearchFilterInitializeAction } from './search-filter.actions'; /** * Interface that represents the state for a single filters 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 19239d899cf14506a4cb37ff3ca3ca179fac39b5..e317a276983ea184fe3655735b6aabb9745524c7 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 @@ -12,8 +12,10 @@ import { import { SearchFiltersState } from './search-filter.reducer'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { FilterType } from '../../search-service/filter-type.model'; +import { SearchFixedFilterService } from './search-fixed-filter.service'; import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; import { of as observableOf } from 'rxjs'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; describe('SearchFilterService', () => { let service: SearchFilterService; @@ -25,6 +27,12 @@ describe('SearchFilterService', () => { isOpenByDefault: false, pageSize: 2 }); + + const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } + } as SearchFixedFilterService const value1 = 'random value'; // const value2 = 'another value'; const store: Store<SearchFiltersState> = jasmine.createSpyObj('store', { @@ -44,11 +52,15 @@ describe('SearchFilterService', () => { }, addQueryParameterValue: (param: string, value: string) => { }, + getQueryParameterValue: (param: string) => { + }, getQueryParameterValues: (param: string) => { return observableOf({}); }, getQueryParamsWithPrefix: (param: string) => { return observableOf({}); + }, + getRouteParameterValue: (param: string) => { } /* tslint:enable:no-empty */ }; @@ -58,7 +70,7 @@ describe('SearchFilterService', () => { }; beforeEach(() => { - service = new SearchFilterService(store, routeServiceStub); + service = new SearchFilterService(store, routeServiceStub, mockFixedFilterService); }); describe('when the initializeFilter method is triggered', () => { @@ -168,4 +180,113 @@ describe('SearchFilterService', () => { }); }); + describe('when the getCurrentScope method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue'); + service.getCurrentScope(); + }); + + it('should call getQueryParameterValue on the route service with scope', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('scope'); + }); + }); + + describe('when the getCurrentQuery method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue'); + service.getCurrentQuery(); + }); + + it('should call getQueryParameterValue on the route service with query', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('query'); + }); + }); + + describe('when the getCurrentPagination method is called', () => { + let result; + const mockReturn = 5; + + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue').and.returnValue(observableOf(mockReturn)); + result = service.getCurrentPagination(); + }); + + it('should call getQueryParameterValue on the route service with page', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('page'); + }); + + it('should call getQueryParameterValue on the route service with pageSize', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('pageSize'); + }); + + it('should return an observable containing the correct pagination', () => { + result.subscribe((pagination) => { + expect(pagination.currentPage).toBe(mockReturn); + expect(pagination.pageSize).toBe(mockReturn); + }); + }); + }); + + describe('when the getCurrentSort method is called', () => { + let result; + const field = 'author'; + const direction = SortDirection.ASC; + + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue').and.returnValue(observableOf(undefined)); + result = service.getCurrentSort(new SortOptions(field, direction)); + }); + + it('should call getQueryParameterValue on the route service with sortDirection', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('sortDirection'); + }); + + it('should call getQueryParameterValue on the route service with sortField', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('sortField'); + }); + + it('should return an observable containing the correct sortOptions', () => { + result.subscribe((sort) => { + expect(sort.field).toBe(field); + expect(sort.direction).toBe(direction); + }); + }); + }); + + describe('when the getCurrentFilters method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParamsWithPrefix'); + service.getCurrentFilters(); + }); + + it('should call getQueryParamsWithPrefix on the route service with prefix \'f.\'', () => { + expect(routeServiceStub.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + }); + }); + + describe('when the getCurrentFixedFilter method is called', () => { + const filter = 'filter'; + + beforeEach(() => { + spyOn(routeServiceStub, 'getRouteParameterValue').and.returnValue(observableOf(filter)); + spyOn(mockFixedFilterService, 'getQueryByFilterName').and.returnValue(observableOf(filter)); + service.getCurrentFixedFilter().subscribe(); + }); + + it('should call getQueryByFilterName on the fixed-filter service with the correct filter', () => { + expect(mockFixedFilterService.getQueryByFilterName).toHaveBeenCalledWith(filter); + }); + }); + + describe('when the getCurrentView method is called', () => { + beforeEach(() => { + spyOn(routeServiceStub, 'getQueryParameterValue'); + service.getCurrentView(); + }); + + it('should call getQueryParameterValue on the route service with view', () => { + expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('view'); + }); + }); + }); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index bed4b1777f66f837c69a1efbb28b96736df76856..4b12417084518443fd52001f713071cc7be27500 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -1,6 +1,6 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators'; import { Injectable, InjectionToken } from '@angular/core'; -import { distinctUntilChanged, map } from 'rxjs/operators'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { @@ -15,12 +15,19 @@ import { 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 { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SearchOptions } from '../../search-options.model'; +import { PaginatedSearchOptions } from '../../paginated-search-options.model'; +import { SearchFixedFilterService } from './search-fixed-filter.service'; +import { Params } from '@angular/router'; +import * as postcss from 'postcss'; +import prefix = postcss.vendor.prefix; // const spy = create(); const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig'); +export const IN_PLACE_SEARCH: InjectionToken<boolean> = new InjectionToken<boolean>('inPlaceSearch'); /** * Service that performs all actions that have to do with search filters and facets @@ -29,8 +36,8 @@ export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionTo export class SearchFilterService { constructor(private store: Store<SearchFiltersState>, - private routeService: RouteService - ) { + private routeService: RouteService, + private fixedFilterService: SearchFixedFilterService) { } /** @@ -52,6 +59,81 @@ export class SearchFilterService { return this.routeService.hasQueryParam(paramName); } + /** + * Fetch the current active scope from the query parameters + * @returns {Observable<string>} + */ + getCurrentScope() { + return this.routeService.getQueryParameterValue('scope'); + } + + /** + * Fetch the current query from the query parameters + * @returns {Observable<string>} + */ + getCurrentQuery() { + return this.routeService.getQueryParameterValue('query'); + } + + /** + * Fetch the current pagination from query parameters 'page' and 'pageSize' + * and combine them with a given pagination + * @param pagination Pagination options to combine the query parameters with + * @returns {Observable<PaginationComponentOptions>} + */ + getCurrentPagination(pagination: any = {}): Observable<PaginationComponentOptions> { + const page$ = this.routeService.getQueryParameterValue('page'); + const size$ = this.routeService.getQueryParameterValue('pageSize'); + return observableCombineLatest(page$, size$).pipe(map(([page, size]) => { + return Object.assign(new PaginationComponentOptions(), pagination, { + currentPage: page || 1, + pageSize: size || pagination.pageSize + }); + })) + } + + /** + * Fetch the current sorting options from query parameters 'sortDirection' and 'sortField' + * and combine them with given sorting options + * @param {SortOptions} defaultSort Sorting options to combine the query parameters with + * @returns {Observable<SortOptions>} + */ + getCurrentSort(defaultSort: SortOptions): Observable<SortOptions> { + const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection'); + const sortField$ = this.routeService.getQueryParameterValue('sortField'); + return observableCombineLatest(sortDirection$, sortField$).pipe(map(([sortDirection, sortField]) => { + const field = sortField || defaultSort.field; + const direction = SortDirection[sortDirection] || defaultSort.direction; + return new SortOptions(field, direction) + } + )) + } + + /** + * Fetch the current active filters from the query parameters + * @returns {Observable<Params>} + */ + getCurrentFilters() { + return this.routeService.getQueryParamsWithPrefix('f.'); + } + + /** + * Fetch the current active fixed filter from the route parameters and return the query by filter name + * @returns {Observable<string>} + */ + getCurrentFixedFilter(): Observable<string> { + const filter: Observable<string> = this.routeService.getRouteParameterValue('filter'); + return filter.pipe(mergeMap((f) => this.fixedFilterService.getQueryByFilterName(f))); + } + + /** + * Fetch the current view from the query parameters + * @returns {Observable<string>} + */ + getCurrentView() { + return this.routeService.getQueryParameterValue('view'); + } + /** * Requests the active filter values set for a given filter * @param {SearchFilterConfig} filterConfig The configuration for which the filters are active @@ -62,7 +144,6 @@ export class SearchFilterService { const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe( map((params: Params) => [].concat(...Object.values(params))), ); - return observableCombineLatest(values$, prefixValues$).pipe( map(([values, prefixValues]) => { if (isNotEmpty(values)) { diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..32073455642581d2e449d62268e9c810f3906dbc --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts @@ -0,0 +1,60 @@ +import { SearchFixedFilterService } from './search-fixed-filter.service'; +import { RouteService } from '../../../shared/services/route.service'; +import { RequestService } from '../../../core/data/request.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { of as observableOf } from 'rxjs'; +import { RequestEntry } from '../../../core/data/request.reducer'; +import { FilteredDiscoveryQueryResponse, RestResponse } from '../../../core/cache/response.models'; + +describe('SearchFixedFilterService', () => { + let service: SearchFixedFilterService; + + const filterQuery = 'filter:query'; + + const routeServiceStub = {} as RouteService; + const requestServiceStub = Object.assign({ + /* tslint:disable:no-empty */ + configure: () => {}, + /* tslint:enable:no-empty */ + generateRequestId: () => 'fake-id', + getByUUID: () => observableOf(Object.assign(new RequestEntry(), { + response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK') + })) + }) as RequestService; + const halServiceStub = Object.assign(new HALEndpointService(requestServiceStub, undefined), { + getEndpoint: () => observableOf('fake-url') + }); + + beforeEach(() => { + service = new SearchFixedFilterService(routeServiceStub, requestServiceStub, halServiceStub); + }); + + describe('when getQueryByFilterName is called with a filterName', () => { + it('should return the filter query', () => { + service.getQueryByFilterName('filter').subscribe((query) => { + expect(query).toBe(filterQuery); + }); + }); + }); + + describe('when getQueryByFilterName is called without a filterName', () => { + it('should return undefined', () => { + service.getQueryByFilterName(undefined).subscribe((query) => { + expect(query).toBeUndefined(); + }); + }); + }); + + describe('when getQueryByRelations is called', () => { + const relationType = 'isRelationOf'; + const itemUUID = 'c5b277e6-2477-48bb-8993-356710c285f3'; + + it('should contain the relationType and itemUUID', () => { + const query = service.getQueryByRelations(relationType, itemUUID); + expect(query.length).toBeGreaterThan(relationType.length + itemUUID.length); + expect(query).toContain(relationType); + expect(query).toContain(itemUUID); + }); + }); + +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d59e5a4461b00998ccbda83e9a76108f18b9882 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@angular/core'; +import { flatMap, map } from 'rxjs/operators'; +import { Observable , of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { GetRequest, RestRequest } from '../../../core/data/request.models'; +import { RequestService } from '../../../core/data/request.service'; +import { ResponseParsingService } from '../../../core/data/parsing.service'; +import { GenericConstructor } from '../../../core/shared/generic-constructor'; +import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service'; +import { hasValue } from '../../../shared/empty.util'; +import { configureRequest, getResponseFromEntry } from '../../../core/shared/operators'; +import { RouteService } from '../../../shared/services/route.service'; +import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models'; + +/** + * Service for performing actions on the filtered-discovery-pages REST endpoint + */ +@Injectable() +export class SearchFixedFilterService { + private queryByFilterPath = 'filtered-discovery-pages'; + + constructor(private routeService: RouteService, + protected requestService: RequestService, + private halService: HALEndpointService) { + + } + + /** + * Get the filter query for a certain filter by name + * @param {string} filterName Name of the filter + * @returns {Observable<string>} Filter query + */ + getQueryByFilterName(filterName: string): Observable<string> { + if (hasValue(filterName)) { + const requestUuid = this.requestService.generateRequestId(); + this.halService.getEndpoint(this.queryByFilterPath).pipe( + map((url: string) => { + url += ('/' + filterName); + const request = new GetRequest(requestUuid, url); + return Object.assign(request, { + getResponseParser(): GenericConstructor<ResponseParsingService> { + return FilteredDiscoveryPageResponseParsingService; + } + }); + }), + configureRequest(this.requestService) + ).subscribe(); + + // get search results from response cache + const filterQuery: Observable<string> = this.requestService.getByUUID(requestUuid).pipe( + getResponseFromEntry(), + map((response: FilteredDiscoveryQueryResponse) => + response.filterQuery + )); + return filterQuery; + } + return observableOf(undefined); + } + + /** + * Get the query for looking up items by relation type + * @param {string} relationType Relation type + * @param {string} itemUUID Item UUID + * @returns {string} Query + */ + getQueryByRelations(relationType: string, itemUUID: string): string { + return `query=relation.${relationType}:${itemUUID}`; + } + + /** + * Get the filter for a relation with the item's UUID + * @param relationType The type of relation e.g. 'isAuthorOfPublication' + * @param itemUUID The item's UUID + */ + getFilterByRelation(relationType: string, itemUUID: string): string { + return `f.${relationType}=${itemUUID}`; + } + +} diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html index b6ae0ada639fb68aa4dc6fae684ff92279817005..ac2a72f4b6356df472bdccf455247271b52c70f7 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,9 +1,9 @@ <div> <div class="filters py-2"> - <ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option> + <ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-selected-option> <ng-container *ngFor="let page of (filterValues$ | async)?.payload"> <div [@facetLoad]="animationState"> - <ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option> + <ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-option> </div> </ng-container> <div class="clearfix toggle-more-filters"> 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 9d35cc518af31ed519ba90ab74e2a23cd4b6026f..cad31e7f0ff58633c86e94ccc63c1b5f98ac5c2d 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,7 +24,7 @@ </ng-container> <ng-container *ngFor="let page of (filterValues$ | async)?.payload"> <div [@facetLoad]="animationState"> - <ds-search-facet-range-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value"></ds-search-facet-range-option> + <ds-search-facet-range-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-range-option> </div> </ng-container> </div> 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 930ea8c9fb2d099a4cc59afc2b24172ee60de44a..119f3f92a9400772f06dc6e98b8a2a9ea603f871 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { FilterType } from '../../../search-service/filter-type.model'; import { FacetValue } from '../../../search-service/facet-value.model'; @@ -18,7 +18,8 @@ import { PageInfo } from '../../../../core/shared/page-info.model'; import { SearchRangeFilterComponent } from './search-range-filter.component'; import { RouteService } from '../../../../shared/services/route.service'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; -import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub'; describe('SearchRangeFilterComponent', () => { let comp: SearchRangeFilterComponent; @@ -41,14 +42,17 @@ describe('SearchRangeFilterComponent', () => { }); const values: FacetValue[] = [ { + label: value1, value: value1, count: 52, search: '' }, { + label: value2, value: value2, count: 20, search: '' }, { + label: value3, value: value3, count: 5, search: '' @@ -73,9 +77,8 @@ describe('SearchRangeFilterComponent', () => { { provide: FILTER_CONFIG, useValue: mockFilterConfig }, { provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} }, { provide: RouteService, useValue: {getQueryParameterValue: () => observableOf({})} }, - { provide: SearchConfigurationService, useValue: { - searchOptions: observableOf({}) } - }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + { provide: IN_PLACE_SEARCH, useValue: false }, { provide: SearchFilterService, useValue: { getSelectedValuesForFilter: () => selectedValues, @@ -116,7 +119,7 @@ describe('SearchRangeFilterComponent', () => { }); it('should call navigate on the router with the right searchlink and parameters', () => { - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { + expect(router.navigate).toHaveBeenCalledWith(searchUrl.split('/'), { queryParams: { [mockFilterConfig.paramName + minSuffix]: [1900], [mockFilterConfig.paramName + maxSuffix]: [1950] diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index ebdb79750036548496ed598d293b264c47002a52..95d7441184e391759fac4be871e7f2ef7450d20b 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 @@ -10,13 +10,14 @@ import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; -import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchService } from '../../../search-service/search.service'; import { Router } from '@angular/router'; import * as moment from 'moment'; import { RouteService } from '../../../../shared/services/route.service'; import { hasValue } from '../../../../shared/empty.util'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; /** * The suffix for a range filters' minimum in the frontend URL @@ -72,13 +73,14 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple constructor(protected searchService: SearchService, protected filterService: SearchFilterService, - protected searchConfigService: SearchConfigurationService, protected router: Router, protected rdbs: RemoteDataBuildService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + @Inject(IN_PLACE_SEARCH) public inPlaceSearch: boolean, @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig, @Inject(PLATFORM_ID) private platformId: any, private route: RouteService) { - super(searchService, filterService, searchConfigService, rdbs, router, filterConfig); + super(searchService, filterService, rdbs, router, searchConfigService, inPlaceSearch, filterConfig); } @@ -107,7 +109,7 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple onSubmit() { const newMin = this.range[0] !== this.min ? [this.range[0]] : null; const newMax = this.range[1] !== this.max ? [this.range[1]] : null; - this.router.navigate([this.getSearchLink()], { + this.router.navigate(this.getSearchLinkParts(), { queryParams: { [this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: newMin, 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 25ff8e46d34003a148e93362849f64ca40f125f2..a4f4fb5ee8f3475c83a90cb55f8bc4fabfc4017a 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,9 +1,9 @@ <div> <div class="filters py-2"> - <ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option> + <ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-selected-option> <ng-container *ngFor="let page of (filterValues$ | async)?.payload"> <div [@facetLoad]="animationState"> - <ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option> + <ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$" [inPlaceSearch]="inPlaceSearch"></ds-search-facet-option> </div> </ng-container> <div class="clearfix toggle-more-filters"> 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 895765f6ac587a4a090b5607dbb91297f86ca6cf..05f4a693c294a2d236727267242d8005748578fd 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 @@ <h3>{{"search.filters.head" | translate}}</h3> <div *ngIf="(filters | async)?.hasSucceeded"> <div *ngFor="let filter of (filters | async)?.payload; trackBy: trackUpdate"> - <ds-search-filter [filter]="filter"></ds-search-filter> + <ds-search-filter [filter]="filter" [inPlaceSearch]="inPlaceSearch"></ds-search-filter> </div> </div> -<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a> \ No newline at end of file +<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a> diff --git a/src/app/+search-page/search-filters/search-filters.component.spec.ts b/src/app/+search-page/search-filters/search-filters.component.spec.ts index db21fc8a69d837149725e710a0d3ade95f9e8549..dc883cd29002fc872839dca447c18784cc28d95d 100644 --- a/src/app/+search-page/search-filters/search-filters.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filters.component.spec.ts @@ -7,13 +7,15 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SearchFilterService } from './search-filter/search-filter.service'; import { SearchFiltersComponent } from './search-filters.component'; import { SearchService } from '../search-service/search.service'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; import { of as observableOf } from 'rxjs'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; describe('SearchFiltersComponent', () => { let comp: SearchFiltersComponent; let fixture: ComponentFixture<SearchFiltersComponent>; let searchService: SearchService; + const searchServiceStub = { /* tslint:disable:no-empty */ getConfig: () => @@ -30,17 +32,13 @@ describe('SearchFiltersComponent', () => { [] }; - const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', { - getCurrentFrontendFilters: observableOf({}) - }); - beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], declarations: [SearchFiltersComponent], providers: [ { provide: SearchService, useValue: searchServiceStub }, - { provide: SearchConfigurationService, useValue: searchConfigServiceStub }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, { provide: SearchFilterService, useValue: searchFiltersStub }, ], diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts index 1dd747e908de700a1f4a4f21dfcb04f07332625e..e970647747f659f0eb1eecfbb1dc11e88896ce32 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -1,13 +1,15 @@ +import { Component, Inject, Input, OnInit } from '@angular/core'; + import { Observable } from 'rxjs'; +import { map, 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 { SearchFilterService } from './search-filter/search-filter.service'; import { getSucceededRemoteData } from '../../core/shared/operators'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-filters', @@ -18,7 +20,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; /** * This component represents the part of the search sidebar that contains filters. */ -export class SearchFiltersComponent { +export class SearchFiltersComponent implements OnInit { /** * An observable containing configuration about which filters are shown and how they are shown */ @@ -30,24 +32,43 @@ export class SearchFiltersComponent { */ clearParams; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Initialize instance variables * @param {SearchService} searchService * @param {SearchConfigurationService} searchConfigService * @param {SearchFilterService} filterService */ - constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) { - this.filters = searchService.getConfig().pipe(getSucceededRemoteData()); - this.clearParams = searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => { + constructor( + private searchService: SearchService, + private filterService: SearchFilterService, + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { + + } + + ngOnInit(): void { + + this.filters = this.searchConfigService.searchOptions.pipe( + switchMap((options) => this.searchService.getConfig(options.scope, options.configuration).pipe(getSucceededRemoteData())) + ); + + this.clearParams = this.searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => { Object.keys(filters).forEach((f) => filters[f] = null); return filters; })); } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } @@ -57,4 +78,5 @@ export class SearchFiltersComponent { trackUpdate(index, config: SearchFilterConfig) { return config ? config.name : undefined; } + } diff --git a/src/app/+search-page/search-labels/search-labels.component.html b/src/app/+search-page/search-labels/search-labels.component.html index 61a5618dada80fb3f33fe5a90ebee2c9a421b00b..cac81e871762ee20efa7e60a677d8b3727e1d93a 100644 --- a/src/app/+search-page/search-labels/search-labels.component.html +++ b/src/app/+search-page/search-labels/search-labels.component.html @@ -2,11 +2,11 @@ <div class="labels col-sm-9 offset-sm-3"> <ng-container *ngFor="let key of ((appliedFilters | async) | dsObjectKeys)"><!--Do not remove this to prevent uneven spacing --><a *ngFor="let values of (appliedFilters | async)[key]" - class="badge badge-primary mr-1 mb-1" + class="badge badge-primary mr-1 mb-1 text-capitalize" [routerLink]="getSearchLink()" [queryParams]="(getRemoveParams(key, values) | async)" queryParamsHandling="merge"> - {{('search.filters.applied.' + key) | translate}}: {{values}} - <span> ×</span> + {{('search.filters.applied.' + key) | translate}}: {{normalizeFilterValue(values)}} + <span> ×</span> </a><!--Do not remove this to prevent uneven spacing --></ng-container> </div> diff --git a/src/app/+search-page/search-labels/search-labels.component.spec.ts b/src/app/+search-page/search-labels/search-labels.component.spec.ts index 81fa5b5df80d0b20260098a2a3ebfe4bb6bbd4a7..d28698764c60e7a47a09e42af3966492aee8f236 100644 --- a/src/app/+search-page/search-labels/search-labels.component.spec.ts +++ b/src/app/+search-page/search-labels/search-labels.component.spec.ts @@ -9,7 +9,8 @@ import { SearchServiceStub } from '../../shared/testing/search-service-stub'; import { Observable, of as observableOf } from 'rxjs'; import { Params } from '@angular/router'; import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; describe('SearchLabelsComponent', () => { let comp: SearchLabelsComponent; @@ -20,8 +21,11 @@ describe('SearchLabelsComponent', () => { const field1 = 'author'; const field2 = 'subject'; - const value1 = 'TestAuthor'; + const value1 = 'Test, Author'; + const normValue1 = 'Test, Author'; const value2 = 'TestSubject'; + const value3 = 'Test, Authority,authority'; + const normValue3 = 'Test, Authority'; const filter1 = [field1, value1]; const filter2 = [field2, value2]; const mockFilters = [ @@ -35,7 +39,8 @@ describe('SearchLabelsComponent', () => { declarations: [SearchLabelsComponent, ObjectKeysPipe], providers: [ { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, - { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } + // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchLabelsComponent, { @@ -65,4 +70,16 @@ describe('SearchLabelsComponent', () => { }); }) }); + + describe('when normalizeFilterValue is called', () => { + it('should return properly filter value', () => { + let result: string; + + result = comp.normalizeFilterValue(value1); + expect(result).toBe(normValue1); + + result = comp.normalizeFilterValue(value3); + expect(result).toBe(normValue3); + }) + }); }); diff --git a/src/app/+search-page/search-labels/search-labels.component.ts b/src/app/+search-page/search-labels/search-labels.component.ts index 08e07cce3d673277dc976077ae50d7b3f6598615..104ed5b08bcaba2f4697443c7930af558f66b9ba 100644 --- a/src/app/+search-page/search-labels/search-labels.component.ts +++ b/src/app/+search-page/search-labels/search-labels.component.ts @@ -1,10 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, Inject, Input } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { Observable } from 'rxjs'; import { Params } from '@angular/router'; import { map } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-labels', @@ -21,10 +22,17 @@ export class SearchLabelsComponent { */ appliedFilters: Observable<Params>; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Initialize the instance variable */ - constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService) { + constructor( + private searchService: SearchService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters(); } @@ -48,9 +56,25 @@ export class SearchLabelsComponent { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - getSearchLink() { + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.searchService.getSearchLink(); } + + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Strips authority operator from filter value + * e.g. 'test ,authority' => 'test' + * + * @param value + */ + normalizeFilterValue(value: string) { + // const pattern = /,[^,]*$/g; + const pattern = /,authority*$/g; + return value.replace(pattern, ''); + } } diff --git a/src/app/+search-page/search-options.model.ts b/src/app/+search-page/search-options.model.ts index 123cf950f80b7a1cb26c246489c76d19ba50e929..2b18854e1eb8109357017d29d2d6877af797bdb5 100644 --- a/src/app/+search-page/search-options.model.ts +++ b/src/app/+search-page/search-options.model.ts @@ -3,21 +3,27 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import 'core-js/library/fn/object/entries'; import { SearchFilter } from './search-filter.model'; import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; +import { SetViewMode } from '../shared/view-mode'; /** * This model class represents all parameters needed to request information about a certain search request */ export class SearchOptions { + configuration?: string; + view?: SetViewMode = SetViewMode.List; scope?: string; query?: string; dsoType?: DSpaceObjectType; - filters?: SearchFilter[]; + filters?: any; + fixedFilter?: any; - constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[]}) { + constructor(options: {configuration?: string, scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any}) { + this.configuration = options.configuration; this.scope = options.scope; this.query = options.query; this.dsoType = options.dsoType; this.filters = options.filters; + this.fixedFilter = options.fixedFilter; } /** @@ -27,7 +33,12 @@ export class SearchOptions { * @returns {string} URL with all search options and passed arguments as query parameters */ toRestUrl(url: string, args: string[] = []): string { - + if (isNotEmpty(this.configuration)) { + args.push(`configuration=${this.configuration}`); + } + if (isNotEmpty(this.fixedFilter)) { + args.push(this.fixedFilter); + } if (isNotEmpty(this.query)) { args.push(`query=${this.query}`); } @@ -39,7 +50,10 @@ export class SearchOptions { } if (isNotEmpty(this.filters)) { this.filters.forEach((filter: SearchFilter) => { - filter.values.forEach((value) => args.push(`${filter.key}=${value},${filter.operator}`)); + filter.values.forEach((value) => { + const filterValue = value.includes(',') ? `${value}` : `${value},${filter.operator}`; + args.push(`${filter.key}=${filterValue}`) + }); }); } if (isNotEmpty(args)) { diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index 65cca99a343c80bb3f670a7a1d859a559765a4f3..8c138c0d527bb1138288f949d2691f08e0c9f3ef 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -2,11 +2,14 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { SearchPageComponent } from './search-page.component'; +import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { FilteredSearchPageGuard } from './filtered-search-page.guard'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', component: SearchPageComponent, data: { title: 'search.title' } } + { path: '', component: SearchPageComponent, data: { title: 'search.title' } }, + { path: ':filter', component: FilteredSearchPageComponent, canActivate: [FilteredSearchPageGuard], data: { title: 'search.' }} ]) ] }) diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 6476f8bd6864855a5a8cd52f9d2a4fedf1399e1f..c11e863429f6250e76f79e8a922b249a4eb14060 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -1,16 +1,17 @@ <div class="container"> <div class="search-page row"> - <ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky" + <ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-{{sideBarWidth}} sidebar-md-sticky" id="search-sidebar" - [resultCount]="(resultsRD$ | async)?.payload.totalElements"></ds-search-sidebar> - <div class="col-12 col-md-9"> - <ds-search-form id="search-form" + [resultCount]="(resultsRD$ | async)?.payload.totalElements" [inPlaceSearch]="inPlaceSearch"></ds-search-sidebar> + <div class="col-12 col-md-{{12 - sideBarWidth}}"> + <ds-search-form *ngIf="searchEnabled" id="search-form" [query]="(searchOptions$ | async)?.query" [scope]="(searchOptions$ | async)?.scope" [currentUrl]="getSearchLink()" - [scopes]="(scopeListRD$ | async)"> + [scopes]="(scopeListRD$ | async)" + [inPlaceSearch]="inPlaceSearch"> </ds-search-form> - <ds-search-labels></ds-search-labels> + <ds-search-labels *ngIf="searchEnabled" [inPlaceSearch]="inPlaceSearch"></ds-search-labels> <div class="row"> <div id="search-body" class="row-offcanvas row-offcanvas-left" @@ -23,7 +24,7 @@ </ds-search-sidebar> <div id="search-content" class="col-12"> <div class="d-block d-md-none search-controls clearfix"> - <ds-view-mode-switch></ds-view-mode-switch> + <ds-view-mode-switch [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch> <button (click)="openSidebar()" aria-controls="#search-body" class="btn btn-outline-primary float-right open-sidebar"><i class="fas fa-sliders"></i> {{"search.sidebar.open" @@ -31,7 +32,9 @@ </button> </div> <ds-search-results [searchResults]="resultsRD$ | async" - [searchConfig]="searchOptions$ | async"></ds-search-results> + [searchConfig]="searchOptions$ | async" + [fixedFilter]="fixedFilter$ | async" + [disableHeader]="!searchEnabled"></ds-search-results> </div> </div> </div> diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index 1991cf8f1b64c36c9cd292d10d2aa2b8c1dff57a..88c7c693d39a84069c4098bcd8c74f76f12bca7a 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -20,91 +20,142 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { RemoteData } from '../core/data/remote-data'; - -describe('SearchPageComponent', () => { - let comp: SearchPageComponent; - let fixture: ComponentFixture<SearchPageComponent>; - let searchServiceObject: SearchService; - const store: Store<SearchPageComponent> = jasmine.createSpyObj('store', { - /* tslint:disable:no-empty */ - dispatch: {}, - /* tslint:enable:no-empty */ - select: observableOf(true) - }); - const pagination: PaginationComponentOptions = new PaginationComponentOptions(); - pagination.id = 'search-results-pagination'; - pagination.currentPage = 1; - pagination.pageSize = 10; - const sort: SortOptions = new SortOptions('score', SortDirection.DESC); - const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); - const searchServiceStub = jasmine.createSpyObj('SearchService', { - search: mockResults, - getSearchLink: '/search', - getScopes: observableOf(['test-scope']) - }); - const queryParam = 'test query'; - const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; - const paginatedSearchOptions = { +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../shared/services/route.service'; +import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; +import { PaginatedSearchOptions } from './paginated-search-options.model'; +import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; + +let comp: SearchPageComponent; +let fixture: ComponentFixture<SearchPageComponent>; +let searchServiceObject: SearchService; +let searchConfigurationServiceObject: SearchConfigurationService; +const store: Store<SearchPageComponent> = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + select: observableOf(true) +}); +const pagination: PaginationComponentOptions = new PaginationComponentOptions(); +pagination.id = 'search-results-pagination'; +pagination.currentPage = 1; +pagination.pageSize = 10; +const sort: SortOptions = new SortOptions('score', SortDirection.DESC); +const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); +const searchServiceStub = jasmine.createSpyObj('SearchService', { + search: mockResults, + getSearchLink: '/search', + getScopes: observableOf(['test-scope']) +}); +const configurationParam = 'default'; +const queryParam = 'test query'; +const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; +const fixedFilter = 'fixed filter'; +const paginatedSearchOptions = new PaginatedSearchOptions({ + configuration: configurationParam, + query: queryParam, + scope: scopeParam, + fixedFilter: fixedFilter, + pagination, + sort +}); +const activatedRouteStub = { + snapshot: { + queryParamMap: new Map([ + ['query', queryParam], + ['scope', scopeParam] + ]) + }, + queryParams: observableOf({ query: queryParam, - scope: scopeParam, - pagination, - sort - }; - const activatedRouteStub = { - queryParams: observableOf({ - query: queryParam, - scope: scopeParam - }) - }; - const sidebarService = { - isCollapsed: observableOf(true), - collapse: () => this.isCollapsed = observableOf(true), - expand: () => this.isCollapsed = observableOf(false) - }; + scope: scopeParam + }) +}; +const sidebarService = { + isCollapsed: observableOf(true), + collapse: () => this.isCollapsed = observableOf(true), + expand: () => this.isCollapsed = observableOf(false) +}; + +const routeServiceStub = { + getRouteParameterValue: () => { + return observableOf(''); + }, + getQueryParameterValue: () => { + return observableOf('') + }, + getQueryParamsWithPrefix: () => { + return observableOf('') + } +}; +const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } +} as SearchFixedFilterService; + +export function configureSearchComponentTestingModule(compType) { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()], + declarations: [compType], + providers: [ + { provide: SearchService, useValue: searchServiceStub }, + { + provide: CommunityDataService, + useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll']) + }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: RouteService, useValue: routeServiceStub }, + { + provide: Store, useValue: store + }, + { + provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', + { + isXs: observableOf(true), + isSm: observableOf(false), + isXsOrSm: observableOf(true) + }) + }, + { + provide: SearchSidebarService, + useValue: sidebarService + }, + { + provide: SearchFilterService, + useValue: {} + }, + { + provide: SearchFixedFilterService, + useValue: mockFixedFilterService + }, + { + provide: SearchConfigurationService, + useValue: { + paginatedSearchOptions: hot('a', { + a: paginatedSearchOptions + }), + getCurrentScope: (a) => observableOf('test-id'), + /* tslint:disable:no-empty */ + updateFixedFilter: (newFilter) => { + } + /* tslint:enable:no-empty */ + } + }, + { + provide: SEARCH_CONFIG_SERVICE, + useValue: new SearchConfigurationServiceStub() + }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(compType, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); +} +describe('SearchPageComponent', () => { beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()], - declarations: [SearchPageComponent], - providers: [ - { provide: SearchService, useValue: searchServiceStub }, - { - provide: CommunityDataService, - useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll']) - }, - { provide: ActivatedRoute, useValue: activatedRouteStub }, - { - provide: Store, useValue: store - }, - { - provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', - { - isXs: observableOf(true), - isSm: observableOf(false), - isXsOrSm: observableOf(true) - }) - }, - { - provide: SearchSidebarService, - useValue: sidebarService - }, - { - provide: SearchFilterService, - useValue: {} - }, { - provide: SearchConfigurationService, - useValue: { - paginatedSearchOptions: hot('a', { - a: paginatedSearchOptions - }), - getCurrentScope: (a) => observableOf('test-id') - } - }, - ], - schemas: [NO_ERRORS_SCHEMA] - }).overrideComponent(SearchPageComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default } - }).compileComponents(); + configureSearchComponentTestingModule(SearchPageComponent); })); beforeEach(() => { @@ -112,25 +163,21 @@ describe('SearchPageComponent', () => { comp = fixture.componentInstance; // SearchPageComponent test instance fixture.detectChanges(); searchServiceObject = (comp as any).service; + searchConfigurationServiceObject = (comp as any).searchConfigService; + }); + + afterEach(() => { + comp = null; + searchServiceObject = null; + searchConfigurationServiceObject = null; }); it('should get the scope and query from the route parameters', () => { + + searchConfigurationServiceObject.paginatedSearchOptions.next(paginatedSearchOptions); expect(comp.searchOptions$).toBeObservable(cold('b', { b: paginatedSearchOptions })); - }); - - describe('when the closeSidebar event is emitted clicked in mobile view', () => { - - beforeEach(() => { - spyOn(comp, 'closeSidebar'); - const closeSidebarButton = fixture.debugElement.query(By.css('#search-sidebar-sm')); - closeSidebarButton.triggerEventHandler('toggleSidebar', null); - }); - - it('should trigger the closeSidebar function', () => { - expect(comp.closeSidebar).toHaveBeenCalled(); - }); }); @@ -177,4 +224,4 @@ describe('SearchPageComponent', () => { }); }); -}) +}); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 0c572a3a843775813f55df4fc688b668d520bd97..2b343ac5848911869952fc2b681bef9279ce9749 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; +import { Observable , Subscription , BehaviorSubject } from 'rxjs'; import { switchMap, } from 'rxjs/operators'; import { PaginatedList } from '../core/data/paginated-list'; import { RemoteData } from '../core/data/remote-data'; @@ -7,13 +7,16 @@ import { DSpaceObject } from '../core/shared/dspace-object.model'; import { pushInOut } from '../shared/animations/push'; import { HostWindowService } from '../shared/host-window.service'; import { PaginatedSearchOptions } from './paginated-search-options.model'; -import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchResult } from './search-result.model'; import { SearchService } from './search-service/search.service'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; -import { hasValue } from '../shared/empty.util'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { getSucceededRemoteData } from '../core/shared/operators'; +import { RouteService } from '../shared/services/route.service'; +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; + +export const SEARCH_ROUTE = '/search'; /** * This component renders a simple item page. @@ -26,11 +29,18 @@ import { getSucceededRemoteData } from '../core/shared/operators'; styleUrls: ['./search-page.component.scss'], templateUrl: './search-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [pushInOut] + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] }) /** * This component represents the whole search page + * It renders search results depending on the current search options */ export class SearchPageComponent implements OnInit { @@ -59,10 +69,34 @@ export class SearchPageComponent implements OnInit { */ sub: Subscription; - constructor(private service: SearchService, - private sidebarService: SearchSidebarService, - private windowService: HostWindowService, - private searchConfigService: SearchConfigurationService) { + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch = true; + + /** + * Whether or not the search bar should be visible + */ + @Input() + searchEnabled = true; + + /** + * The width of the sidebar (bootstrap columns) + */ + @Input() + sideBarWidth = 3; + + /** + * The currently applied filter (determines title of search) + */ + @Input() + fixedFilter$: Observable<string>; + + constructor(protected service: SearchService, + protected sidebarService: SearchSidebarService, + protected windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + protected routeService: RouteService) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -74,7 +108,7 @@ export class SearchPageComponent implements OnInit { * If something changes, update the list of scopes for the dropdown */ ngOnInit(): void { - this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; + this.searchOptions$ = this.getSearchOptions(); this.sub = this.searchOptions$.pipe( switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))) .subscribe((results) => { @@ -83,6 +117,17 @@ export class SearchPageComponent implements OnInit { this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( switchMap((scopeId) => this.service.getScopes(scopeId)) ); + if (!isNotEmpty(this.fixedFilter$)) { + this.fixedFilter$ = this.routeService.getRouteParameterValue('filter'); + } + } + + /** + * Get the current paginated search options + * @returns {Observable<PaginatedSearchOptions>} + */ + protected getSearchOptions(): Observable<PaginatedSearchOptions> { + return this.searchConfigService.paginatedSearchOptions; } /** @@ -108,9 +153,12 @@ export class SearchPageComponent implements OnInit { } /** - * @returns {string} The base path to the search page + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } return this.service.getSearchLink(); } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index dc895e5d703f4f40d7daa79da208019c947935f9..65558eae175e1f934ff2eaa0ef7bab0dc54ac0c7 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -5,9 +5,6 @@ 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'; @@ -20,6 +17,9 @@ import { SearchFiltersComponent } from './search-filters/search-filters.componen import { SearchFilterComponent } from './search-filters/search-filter/search-filter.component'; import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; +import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; +import { FilteredSearchPageGuard } from './filtered-search-page.guard'; import { SearchLabelsComponent } from './search-labels/search-labels.component'; import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component'; import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component'; @@ -30,11 +30,39 @@ import { SearchConfigurationService } from './search-service/search-configuratio import { SearchFacetOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component'; import { SearchFacetSelectedOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component'; import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; +import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component'; +import { SearchAuthorityFilterComponent } from './search-filters/search-filter/search-authority-filter/search-authority-filter.component'; const effects = [ SearchSidebarEffects ]; +const components = [ + SearchPageComponent, + SearchResultsComponent, + SearchSidebarComponent, + SearchSettingsComponent, + ItemSearchResultGridElementComponent, + CollectionSearchResultGridElementComponent, + CommunitySearchResultGridElementComponent, + SearchFiltersComponent, + SearchFilterComponent, + SearchFacetFilterComponent, + SearchLabelsComponent, + SearchFacetFilterComponent, + SearchFacetFilterWrapperComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent, + SearchSwitchConfigurationComponent, + SearchAuthorityFilterComponent, + FilteredSearchPageComponent +]; + @NgModule({ imports: [ SearchPageRoutingModule, @@ -43,37 +71,16 @@ const effects = [ EffectsModule.forFeature(effects), CoreModule.forRoot() ], - declarations: [ - SearchPageComponent, - SearchResultsComponent, - SearchSidebarComponent, - SearchSettingsComponent, - ItemSearchResultGridElementComponent, - CollectionSearchResultGridElementComponent, - CommunitySearchResultGridElementComponent, - SearchFiltersComponent, - SearchFilterComponent, - SearchFacetFilterComponent, - SearchLabelsComponent, - SearchFacetFilterComponent, - SearchFacetFilterWrapperComponent, - SearchRangeFilterComponent, - SearchTextFilterComponent, - SearchHierarchyFilterComponent, - SearchBooleanFilterComponent, - SearchFacetOptionComponent, - SearchFacetSelectedOptionComponent, - SearchFacetRangeOptionComponent - ], + declarations: components, providers: [ SearchSidebarService, SearchFilterService, + SearchFixedFilterService, + FilteredSearchPageGuard, + SearchFilterService, SearchConfigurationService ], entryComponents: [ - ItemSearchResultListElementComponent, - CollectionSearchResultListElementComponent, - CommunitySearchResultListElementComponent, ItemSearchResultGridElementComponent, CollectionSearchResultGridElementComponent, CommunitySearchResultGridElementComponent, @@ -84,8 +91,10 @@ const effects = [ SearchBooleanFilterComponent, SearchFacetOptionComponent, SearchFacetSelectedOptionComponent, - SearchFacetRangeOptionComponent - ] + SearchFacetRangeOptionComponent, + SearchAuthorityFilterComponent + ], + exports: components }) /** diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts index ff865610c6c0ddbd6feab86be0ed1d236e3597b3..0354edbc6b23692f00b39d785867da106a8d248c 100644 --- a/src/app/+search-page/search-result.model.ts +++ b/src/app/+search-page/search-result.model.ts @@ -9,7 +9,7 @@ export class SearchResult<T extends DSpaceObject> implements ListableObject { /** * The DSpaceObject that was found */ - dspaceObject: T; + indexableObject: T; /** * The metadata that was used to find this item, hithighlighted diff --git a/src/app/+search-page/search-results/search-results.component.html b/src/app/+search-page/search-results/search-results.component.html index 4915b552c37285c5a964c933956c5651e796294c..d7ecb0357ee5b92e84794916f56013f602089336 100644 --- a/src/app/+search-page/search-results/search-results.component.html +++ b/src/app/+search-page/search-results/search-results.component.html @@ -1,4 +1,4 @@ -<h2>{{ 'search.results.head' | translate }}</h2> +<h2 *ngIf="!disableHeader">{{ getTitleKey() | translate }}</h2> <div *ngIf="searchResults?.hasSucceeded && !searchResults?.isLoading && searchResults?.payload?.page.length > 0" @fadeIn> <ds-viewable-collection [config]="searchConfig.pagination" 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 8d0566d1df2c1ac1ada6dc3a3acff140a17a14e4..518829e69f831c30e3d4a4f79665c93877b31aea 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 @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed, async, tick, fakeAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { ResourceType } from '../../core/shared/resource-type'; import { Community } from '../../core/shared/community.model'; import { TranslateModule } from '@ngx-translate/core'; @@ -11,6 +11,8 @@ import { QueryParamsDirectiveStub } from '../../shared/testing/query-params-dire describe('SearchResultsComponent', () => { let comp: SearchResultsComponent; let fixture: ComponentFixture<SearchResultsComponent>; + let heading: DebugElement; + let title: DebugElement; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -24,7 +26,9 @@ describe('SearchResultsComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SearchResultsComponent); - comp = fixture.componentInstance; // SearchResultsComponent test instance + comp = fixture.componentInstance; // SearchFormComponent test instance + heading = fixture.debugElement.query(By.css('heading')); + title = fixture.debugElement.query(By.css('h2')); }); it('should display results when results are not empty', () => { diff --git a/src/app/+search-page/search-results/search-results.component.ts b/src/app/+search-page/search-results/search-results.component.ts index ae0abfcd276610b290fdd52c7509ed23dc597896..9656ba95748d392ac261ffc198dc948ced8d9ab3 100644 --- a/src/app/+search-page/search-results/search-results.component.ts +++ b/src/app/+search-page/search-results/search-results.component.ts @@ -2,11 +2,12 @@ import { Component, Input } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { SetViewMode } from '../../shared/view-mode'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; import { PaginatedList } from '../../core/data/paginated-list'; -import { ViewMode } from '../../core/shared/view-mode.model'; import { isNotEmpty } from '../../shared/empty.util'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-search-results', @@ -32,9 +33,38 @@ export class SearchResultsComponent { @Input() searchConfig: SearchOptions; /** - * The current view mode for the search results + * The current sorting configuration of the search */ - @Input() viewMode: ViewMode; + @Input() sortConfig: SortOptions; + + /** + * The current view-mode of the list + */ + @Input() viewMode: SetViewMode; + + /** + * An optional fixed filter to filter the result on one type + */ + @Input() fixedFilter: string; + + /** + * Whether or not to hide the header of the results + * Defaults to a visible header + */ + @Input() disableHeader = false; + + /** + * Get the i18n key for the title depending on the fixed filter + * Defaults to 'search.results.head' if there's no fixed filter found + * @returns {string} + */ + getTitleKey() { + if (isNotEmpty(this.fixedFilter)) { + return 'search.' + this.fixedFilter + '.results.head' + } else { + return 'search.results.head'; + } + } /** * Method to change the given string by surrounding it by quotes if not already present. diff --git a/src/app/+search-page/search-service/facet-value.model.ts b/src/app/+search-page/search-service/facet-value.model.ts index 0f673f34850bda41531781193ba42da94c2faaed..d2cc521356fa5be8ba9465f5fec75581a0a63cce 100644 --- a/src/app/+search-page/search-service/facet-value.model.ts +++ b/src/app/+search-page/search-service/facet-value.model.ts @@ -5,7 +5,13 @@ import { autoserialize, autoserializeAs } from 'cerialize'; */ export class FacetValue { /** - * The display value of the facet value + * The display label of the facet value + */ + @autoserialize + label: string; + + /** + * The value of the facet value */ @autoserializeAs(String, 'label') value: string; diff --git a/src/app/+search-page/search-service/filter-type.model.ts b/src/app/+search-page/search-service/filter-type.model.ts index d9b96293479b1f1bab897b9f31792bc8b4f58e18..d5a338de6da8ad933a540a0a7bcc526c2a5dc4b7 100644 --- a/src/app/+search-page/search-service/filter-type.model.ts +++ b/src/app/+search-page/search-service/filter-type.model.ts @@ -2,6 +2,11 @@ * Enumeration containing all possible types for filters */ export enum FilterType { + /** + * Represents authority facets + */ + authority = 'authority', + /** * Represents simple text facets */ diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/+search-page/search-service/search-configuration.service.spec.ts index f1f4ef8bdce8950dae78dce3feb78972ebfdc162..79932805c1013a330e13cd1a8acc8d8f2b3a8bfe 100644 --- a/src/app/+search-page/search-service/search-configuration.service.spec.ts +++ b/src/app/+search-page/search-service/search-configuration.service.spec.ts @@ -17,23 +17,28 @@ describe('SearchConfigurationService', () => { const defaults = new PaginatedSearchOptions({ pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }), sort: new SortOptions('score', SortDirection.DESC), + configuration: 'default', query: '', scope: '' }); const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])]; - const spy = jasmine.createSpyObj('RouteService', { + const routeService = jasmine.createSpyObj('RouteService', { getQueryParameterValue: observableOf(value1), - getQueryParamsWithPrefix: observableOf(prefixFilter) + getQueryParamsWithPrefix: observableOf(prefixFilter), + getRouteParameterValue: observableOf('') + }); + + const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { + getQueryByFilterName: observableOf(''), }); const activatedRoute: any = new ActivatedRouteStub(); beforeEach(() => { - service = new SearchConfigurationService(spy, activatedRoute); + service = new SearchConfigurationService(routeService, fixedFilterService, activatedRoute); }); - describe('when the scope is called', () => { beforeEach(() => { service.getCurrentScope(''); @@ -43,6 +48,15 @@ describe('SearchConfigurationService', () => { }); }); + describe('when getCurrentConfiguration is called', () => { + beforeEach(() => { + service.getCurrentConfiguration(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'configuration\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('configuration'); + }); + }); + describe('when getCurrentQuery is called', () => { beforeEach(() => { service.getCurrentQuery(''); @@ -94,6 +108,7 @@ describe('SearchConfigurationService', () => { expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField'); }); }); + describe('when getCurrentPagination is called', () => { beforeEach(() => { service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any); @@ -105,11 +120,13 @@ describe('SearchConfigurationService', () => { expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize'); }); }); + describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => { beforeEach(() => { spyOn(service, 'getCurrentPagination').and.callThrough(); spyOn(service, 'getCurrentSort').and.callThrough(); spyOn(service, 'getCurrentScope').and.callThrough(); + spyOn(service, 'getCurrentConfiguration').and.callThrough(); spyOn(service, 'getCurrentQuery').and.callThrough(); spyOn(service, 'getCurrentDSOType').and.callThrough(); spyOn(service, 'getCurrentFilters').and.callThrough(); @@ -123,6 +140,7 @@ describe('SearchConfigurationService', () => { expect(service.getCurrentPagination).not.toHaveBeenCalled(); expect(service.getCurrentSort).not.toHaveBeenCalled(); expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); expect(service.getCurrentQuery).toHaveBeenCalled(); expect(service.getCurrentDSOType).toHaveBeenCalled(); expect(service.getCurrentFilters).toHaveBeenCalled(); @@ -137,10 +155,36 @@ describe('SearchConfigurationService', () => { expect(service.getCurrentPagination).toHaveBeenCalled(); expect(service.getCurrentSort).toHaveBeenCalled(); expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); expect(service.getCurrentQuery).toHaveBeenCalled(); expect(service.getCurrentDSOType).toHaveBeenCalled(); expect(service.getCurrentFilters).toHaveBeenCalled(); }); }); }); + + describe('when getCurrentFixedFilter is called', () => { + beforeEach(() => { + service.getCurrentFixedFilter(); + }); + it('should call getRouteParameterValue on the routeService with parameter name \'filter\'', () => { + expect((service as any).routeService.getRouteParameterValue).toHaveBeenCalledWith('filter'); + }); + }); + + describe('when updateFixedFilter is called', () => { + const filter = 'filter'; + + beforeEach(() => { + service.updateFixedFilter(filter); + }); + + it('should update the paginated search options with the correct fixed filter', () => { + expect(service.paginatedSearchOptions.getValue().fixedFilter).toEqual(filter); + }); + + it('should update the search options with the correct fixed filter', () => { + expect(service.searchOptions.getValue().fixedFilter).toEqual(filter); + }); + }); }); diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts index 7ba1ebd75f9b5db62f1f068fabda07b0fb7462a2..39acd19ccda8780b8972c1331827ae2a595e3586 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -1,3 +1,6 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; + import { BehaviorSubject, combineLatest as observableCombineLatest, @@ -6,19 +9,18 @@ import { of as observableOf, Subscription } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { filter, flatMap, map } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SearchOptions } from '../search-options.model'; -import { ActivatedRoute, Params } from '@angular/router'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { Injectable, OnDestroy } from '@angular/core'; import { RouteService } from '../../shared/services/route.service'; -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from '../../core/data/remote-data'; import { getSucceededRemoteData } from '../../core/shared/operators'; import { SearchFilter } from '../search-filter.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; +import { SearchFixedFilterService } from '../search-filters/search-filter/search-fixed-filter.service'; /** * Service that performs all actions that have to do with the current search configuration @@ -28,7 +30,7 @@ export class SearchConfigurationService implements OnDestroy { /** * Default pagination settings */ - private defaultPagination = Object.assign(new PaginationComponentOptions(), { + protected defaultPagination = Object.assign(new PaginationComponentOptions(), { id: 'search-page-configuration', pageSize: 10, currentPage: 1 @@ -37,22 +39,27 @@ export class SearchConfigurationService implements OnDestroy { /** * Default sort settings */ - private defaultSort = new SortOptions('score', SortDirection.DESC); + protected defaultSort = new SortOptions('score', SortDirection.DESC); + + /** + * Default configuration parameter setting + */ + protected defaultConfiguration = 'default'; /** * Default scope setting */ - private defaultScope = ''; + protected defaultScope = ''; /** * Default query setting */ - private defaultQuery = ''; + protected defaultQuery = ''; /** * Emits the current default values */ - private _defaults: Observable<RemoteData<PaginatedSearchOptions>>; + protected _defaults: Observable<RemoteData<PaginatedSearchOptions>>; /** * Emits the current search options @@ -67,28 +74,48 @@ export class SearchConfigurationService implements OnDestroy { /** * List of subscriptions to unsubscribe from on destroy */ - private subs: Subscription[] = new Array(); + protected subs: Subscription[] = new Array(); /** * Initialize the search options * @param {RouteService} routeService + * @param {SearchFixedFilterService} fixedFilterService * @param {ActivatedRoute} route */ - constructor(private routeService: RouteService, - private route: ActivatedRoute) { + constructor(protected routeService: RouteService, + protected fixedFilterService: SearchFixedFilterService, + protected route: ActivatedRoute) { + + this.initDefaults(); + } + + /** + * Initialize the search options + */ + protected initDefaults() { this.defaults .pipe(getSucceededRemoteData()) .subscribe((defRD) => { const defs = defRD.payload; - this.paginatedSearchOptions = new BehaviorSubject<SearchOptions>(defs); - this.searchOptions = new BehaviorSubject<PaginatedSearchOptions>(defs); + this.paginatedSearchOptions = new BehaviorSubject<PaginatedSearchOptions>(defs); + this.searchOptions = new BehaviorSubject<SearchOptions>(defs); this.subs.push(this.subscribeToSearchOptions(defs)); this.subs.push(this.subscribeToPaginatedSearchOptions(defs)); + } ) } + /** + * @returns {Observable<string>} Emits the current configuration string + */ + getCurrentConfiguration(defaultConfiguration: string) { + return this.routeService.getQueryParameterValue('configuration').pipe(map((configuration) => { + return configuration || defaultConfiguration; + })); + } + /** * @returns {Observable<string>} Emits the current scope's identifier */ @@ -112,7 +139,7 @@ export class SearchConfigurationService implements OnDestroy { */ getCurrentDSOType(): Observable<DSpaceObjectType> { return this.routeService.getQueryParameterValue('dsoType').pipe( - filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()])), + filter((type) => isNotEmpty(type) && hasValue(DSpaceObjectType[type.toUpperCase()])), map((type) => DSpaceObjectType[type.toUpperCase()]),); } @@ -174,6 +201,15 @@ export class SearchConfigurationService implements OnDestroy { })); } + /** + * @returns {Observable<string>} Emits the current fixed filter as a string + */ + getCurrentFixedFilter(): Observable<string> { + return this.routeService.getRouteParameterValue('filter').pipe( + flatMap((f) => this.fixedFilterService.getQueryByFilterName(f)) + ); + } + /** * @returns {Observable<Params>} Emits the current active filters with their values as they are displayed in the frontend URL */ @@ -188,10 +224,12 @@ export class SearchConfigurationService implements OnDestroy { */ private subscribeToSearchOptions(defaults: SearchOptions): Subscription { return observableMerge( + this.getConfigurationPart(defaults.configuration), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), this.getDSOTypePart(), - this.getFiltersPart() + this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: SearchOptions = this.searchOptions.getValue(); const updatedValue: SearchOptions = Object.assign(currentValue, update); @@ -208,10 +246,12 @@ export class SearchConfigurationService implements OnDestroy { return observableMerge( this.getPaginationPart(defaults.pagination), this.getSortPart(defaults.sort), + this.getConfigurationPart(defaults.configuration), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), this.getDSOTypePart(), - this.getFiltersPart() + this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update); @@ -226,6 +266,7 @@ export class SearchConfigurationService implements OnDestroy { if (hasNoValue(this._defaults)) { const options = new PaginatedSearchOptions({ pagination: this.defaultPagination, + configuration: this.defaultConfiguration, sort: this.defaultSort, scope: this.defaultScope, query: this.defaultQuery @@ -242,6 +283,16 @@ export class SearchConfigurationService implements OnDestroy { this.subs.forEach((sub) => { sub.unsubscribe(); }); + this.subs = []; + } + + /** + * @returns {Observable<string>} Emits the current configuration settings as a partial SearchOptions object + */ + private getConfigurationPart(defaultConfiguration: string): Observable<any> { + return this.getCurrentConfiguration(defaultConfiguration).pipe(map((configuration) => { + return { configuration } + })); } /** @@ -297,4 +348,30 @@ export class SearchConfigurationService implements OnDestroy { return { filters } })); } + + /** + * @returns {Observable<string>} Emits the current fixed filter as a partial SearchOptions object + */ + private getFixedFilterPart(): Observable<any> { + return this.getCurrentFixedFilter().pipe( + isNotEmptyOperator(), + map((fixedFilter) => { + return { fixedFilter } + }) + ); + } + + /** + * Update the fixed filter in paginated and non-paginated search options with a given value + * @param {string} fixedFilter + */ + public updateFixedFilter(fixedFilter: string) { + const currentPaginatedValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); + const updatedPaginatedValue: PaginatedSearchOptions = Object.assign(currentPaginatedValue, { fixedFilter: fixedFilter }); + this.paginatedSearchOptions.next(updatedPaginatedValue); + + const currentValue: SearchOptions = this.searchOptions.getValue(); + const updatedValue: SearchOptions = Object.assign(currentValue, { fixedFilter: fixedFilter }); + this.searchOptions.next(updatedValue); + } } diff --git a/src/app/+search-page/search-service/search-query-response.model.ts b/src/app/+search-page/search-service/search-query-response.model.ts index ac1d8b7df339f22a549dedc57afaae29d07a68e8..bca6e644fc4a54b82339acfadccc231e52f38e8b 100644 --- a/src/app/+search-page/search-service/search-query-response.model.ts +++ b/src/app/+search-page/search-service/search-query-response.model.ts @@ -34,7 +34,7 @@ export class SearchQueryResponse { * The sort parameters used in the search request */ @autoserialize - configurationName: string; + configuration: string; /** * The sort parameters used in the search request diff --git a/src/app/+search-page/search-service/search-result-element-decorator.ts b/src/app/+search-page/search-service/search-result-element-decorator.ts index 348cf7f592ce00ec37c392230bd00decd9d310d2..59446480a3af0577aa61ced3b87de71f46842bfc 100644 --- a/src/app/+search-page/search-service/search-result-element-decorator.ts +++ b/src/app/+search-page/search-service/search-result-element-decorator.ts @@ -1,5 +1,6 @@ import { GenericConstructor } from '../../core/shared/generic-constructor'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { isNull } from '../../shared/empty.util'; /** * Contains the mapping between a search result component and a DSpaceObject @@ -11,12 +12,19 @@ const searchResultMap = new Map(); * @param {GenericConstructor<ListableObject>} domainConstructor The constructor of the DSpaceObject * @returns Decorator function that performs the actual mapping on initialization of the component */ -export function searchResultFor(domainConstructor: GenericConstructor<ListableObject>) { +export function searchResultFor(domainConstructor: GenericConstructor<ListableObject>, configuration: string = null) { return function decorator(searchResult: any) { if (!searchResult) { return; } - searchResultMap.set(domainConstructor, searchResult); + if (isNull(configuration)) { + searchResultMap.set(domainConstructor, searchResult); + } else { + if (!searchResultMap.get(configuration)) { + searchResultMap.set(configuration, new Map()); + } + searchResultMap.get(configuration).set(domainConstructor, searchResult); + } }; } @@ -25,6 +33,10 @@ export function searchResultFor(domainConstructor: GenericConstructor<ListableOb * @param {GenericConstructor<ListableObject>} domainConstructor The DSpaceObject's constructor for which the search result component is requested * @returns The component's constructor that matches the given DSpaceObject */ -export function getSearchResultFor(domainConstructor: GenericConstructor<ListableObject>) { - return searchResultMap.get(domainConstructor); +export function getSearchResultFor(domainConstructor: GenericConstructor<ListableObject>, configuration: string = null) { + if (isNull(configuration) || configuration === 'default') { + return searchResultMap.get(domainConstructor); + } else { + return searchResultMap.get(configuration).get(domainConstructor); + } } diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index ca48b02aa72e54111dc903104b7f91a2749c3588..9ec5bc35f212fedc72ca371d22396df6907c4cd5 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -5,28 +5,29 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; import { SearchService } from './search.service'; +import { ItemDataService } from './../../core/data/item-data.service'; +import { SetViewMode } from '../../shared/view-mode'; +import { GLOBAL_CONFIG } from '../../../config'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; -import { ActivatedRoute, Router, UrlTree } from '@angular/router'; +import { Router, UrlTree } from '@angular/router'; import { RequestService } from '../../core/data/request.service'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { RouterStub } from '../../shared/testing/router-stub'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { Observable, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { RemoteData } from '../../core/data/remote-data'; import { RequestEntry } from '../../core/data/request.reducer'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { - FacetConfigSuccessResponse, - SearchSuccessResponse -} from '../../core/cache/response.models'; +import { FacetConfigSuccessResponse, SearchSuccessResponse } from '../../core/cache/response.models'; import { SearchQueryResponse } from './search-query-response.model'; import { SearchFilterConfig } from './search-filter-config.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { of as observableOf } from 'rxjs'; import { map } from 'rxjs/operators'; +import { RouteService } from '../../shared/services/route.service'; +import { routeServiceStub } from '../../shared/testing/route-service-stub'; @Component({ template: '' }) class DummyComponent { @@ -50,7 +51,7 @@ describe('SearchService', () => { ], providers: [ { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: route }, + { provide: RouteService, useValue: routeServiceStub }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, @@ -71,7 +72,7 @@ describe('SearchService', () => { describe('', () => { let searchService: SearchService; const router = new RouterStub(); - const route = new ActivatedRouteStub(); + let routeService; const halService = { /* tslint:disable:no-empty */ @@ -107,7 +108,7 @@ describe('SearchService', () => { ], providers: [ { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: route }, + { provide: RouteService, useValue: routeServiceStub }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: HALEndpointService, useValue: halService }, @@ -117,6 +118,7 @@ describe('SearchService', () => { ], }); searchService = TestBed.get(SearchService); + routeService = TestBed.get(RouteService); const urlTree = Object.assign(new UrlTree(), { root: { children: { primary: 'search' } } }); router.parseUrl.and.returnValue(urlTree); }); @@ -124,7 +126,7 @@ describe('SearchService', () => { it('should call the navigate method on the Router with view mode list parameter as a parameter when setViewMode is called', () => { searchService.setViewMode(ViewMode.List); expect(router.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { view: ViewMode.List }, + queryParams: { view: ViewMode.List, page: 1 }, queryParamsHandling: 'merge' }); }); @@ -132,21 +134,26 @@ describe('SearchService', () => { it('should call the navigate method on the Router with view mode grid parameter as a parameter when setViewMode is called', () => { searchService.setViewMode(ViewMode.Grid); expect(router.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { view: ViewMode.Grid }, + queryParams: { view: ViewMode.Grid, page: 1 }, queryParamsHandling: 'merge' }); }); it('should return ViewMode.List when the viewMode is set to ViewMode.List in the ActivatedRoute', () => { let viewMode = ViewMode.Grid; - route.testParams = { view: ViewMode.List }; + spyOn(routeService, 'getQueryParamMap').and.returnValue(observableOf(new Map([ + [ 'view', ViewMode.List ], + ]))); + searchService.getViewMode().subscribe((mode) => viewMode = mode); expect(viewMode).toEqual(ViewMode.List); }); it('should return ViewMode.Grid when the viewMode is set to ViewMode.Grid in the ActivatedRoute', () => { let viewMode = ViewMode.List; - route.testParams = { view: ViewMode.Grid }; + spyOn(routeService, 'getQueryParamMap').and.returnValue(observableOf(new Map([ + [ 'view', ViewMode.Grid ], + ]))); searchService.getViewMode().subscribe((mode) => viewMode = mode); expect(viewMode).toEqual(ViewMode.Grid); }); diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 2c0f1f4e55d407ea045b493e52da5d779741894b..598657a1b288cc03a97c96bea3b0bd50002a45d0 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,13 +1,7 @@ import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; -import { - ActivatedRoute, - NavigationExtras, - PRIMARY_OUTLET, - Router, - UrlSegmentGroup -} from '@angular/router'; -import { map, switchMap, tap } from 'rxjs/operators'; +import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; +import { first, map, switchMap } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { FacetConfigSuccessResponse, @@ -23,12 +17,13 @@ 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, filterSuccessfulResponses, + configureRequest, + filterSuccessfulResponses, getResponseFromEntry, getSucceededRemoteData } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { NormalizedSearchResult } from '../normalized-search-result.model'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; @@ -47,6 +42,7 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { ResourceType } from '../../core/shared/resource-type'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { RouteService } from '../../shared/services/route.service'; /** * Service that performs all general actions that have to do with the search page @@ -63,13 +59,23 @@ export class SearchService implements OnDestroy { */ private facetLinkPathPrefix = 'discover/facets/'; + /** + * The ResponseParsingService constructor name + */ + private parser: GenericConstructor<ResponseParsingService> = SearchResponseParsingService; + + /** + * The RestRequest constructor name + */ + private request: GenericConstructor<RestRequest> = GetRequest; + /** * Subscription to unsubscribe from */ private sub; constructor(private router: Router, - private route: ActivatedRoute, + private routeService: RouteService, protected requestService: RequestService, private rdb: RemoteDataBuildService, private halService: HALEndpointService, @@ -78,6 +84,20 @@ export class SearchService implements OnDestroy { ) { } + /** + * Method to set service options + * @param {GenericConstructor<ResponseParsingService>} parser The ResponseParsingService constructor name + * @param {boolean} request The RestRequest constructor name + */ + setServiceOptions(parser: GenericConstructor<ResponseParsingService>, request: GenericConstructor<RestRequest>) { + if (parser) { + this.parser = parser; + } + if (request) { + this.request = request; + } + } + /** * Method to retrieve a paginated list of search results from the server * @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search @@ -89,14 +109,17 @@ export class SearchService implements OnDestroy { if (hasValue(searchOptions)) { url = (searchOptions as PaginatedSearchOptions).toRestUrl(url); } - const request = new GetRequest(this.requestService.generateRequestId(), url); + const request = new this.request(this.requestService.generateRequestId(), url); + + const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => { + return this.parser; + }; + return Object.assign(request, { - getResponseParser(): GenericConstructor<ResponseParsingService> { - return SearchResponseParsingService; - } + getResponseParser: getResponseParserFn }); }), - configureRequest(this.requestService) + configureRequest(this.requestService), ); const requestEntryObs = requestObs.pipe( switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) @@ -112,8 +135,10 @@ export class SearchService implements OnDestroy { // Turn list of observable remote data DSO's into observable remote data object with list of DSO const dsoObs: Observable<RemoteData<DSpaceObject[]>> = sqrObs.pipe( map((sqr: SearchQueryResponse) => { - return sqr.objects.map((nsr: NormalizedSearchResult) => { - return this.rdb.buildSingle(nsr.dspaceObject); + return sqr.objects + .filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject)) + .map((nsr: NormalizedSearchResult) => { + return this.rdb.buildSingle(nsr.indexableObject); }) }), switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)), @@ -126,9 +151,9 @@ export class SearchService implements OnDestroy { let co = DSpaceObject; if (dsos.payload[index]) { const constructor: GenericConstructor<ListableObject> = dsos.payload[index].constructor as GenericConstructor<ListableObject>; - co = getSearchResultFor(constructor); + co = getSearchResultFor(constructor, searchOptions.configuration); return Object.assign(new co(), object, { - dspaceObject: dsos.payload[index] + indexableObject: dsos.payload[index] }); } else { return undefined; @@ -154,9 +179,10 @@ export class SearchService implements OnDestroy { /** * Request the filter configuration for a given scope or the whole repository * @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded + * @param {string} configurationName the name of the configuration * @returns {Observable<RemoteData<SearchFilterConfig[]>>} The found filter configuration */ - getConfig(scope?: string): Observable<RemoteData<SearchFilterConfig[]>> { + getConfig(scope?: string, configurationName?: string): Observable<RemoteData<SearchFilterConfig[]>> { const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe( map((url: string) => { const args: string[] = []; @@ -165,11 +191,15 @@ export class SearchService implements OnDestroy { args.push(`scope=${scope}`); } + if (isNotEmpty(configurationName)) { + args.push(`configuration=${configurationName}`); + } + if (isNotEmpty(args)) { url = new URLCombiner(url, `?${args.join('&')}`).toString(); } - const request = new GetRequest(this.requestService.generateRequestId(), url); + const request = new this.request(this.requestService.generateRequestId(), url); return Object.assign(request, { getResponseParser(): GenericConstructor<ResponseParsingService> { return FacetConfigResponseParsingService; @@ -212,14 +242,15 @@ export class SearchService implements OnDestroy { url = searchOptions.toRestUrl(url, args); } - const request = new GetRequest(this.requestService.generateRequestId(), url); + const request = new this.request(this.requestService.generateRequestId(), url); return Object.assign(request, { getResponseParser(): GenericConstructor<ResponseParsingService> { return FacetValueResponseParsingService; } }); }), - configureRequest(this.requestService) + configureRequest(this.requestService), + first() ); const requestEntryObs = requestObs.pipe( @@ -288,9 +319,9 @@ export class SearchService implements OnDestroy { * @returns {Observable<ViewMode>} The current view mode */ getViewMode(): Observable<ViewMode> { - return this.route.queryParams.pipe(map((params) => { - if (isNotEmpty(params.view) && hasValue(params.view)) { - return params.view; + return this.routeService.getQueryParamMap().pipe(map((params) => { + if (isNotEmpty(params.get('view')) && hasValue(params.get('view'))) { + return params.get('view'); } else { return ViewMode.List; } @@ -301,22 +332,29 @@ export class SearchService implements OnDestroy { * Changes the current view mode in the current URL * @param {ViewMode} viewMode Mode to switch to */ - setViewMode(viewMode: ViewMode) { - const navigationExtras: NavigationExtras = { - queryParams: { view: viewMode }, - queryParamsHandling: 'merge' - }; + setViewMode(viewMode: ViewMode, searchLinkParts?: string[]) { + this.routeService.getQueryParameterValue('pageSize').pipe(first()) + .subscribe((pageSize) => { + let queryParams = { view: viewMode, page: 1 }; + if (viewMode === ViewMode.Detail) { + queryParams = Object.assign(queryParams, {pageSize: '1'}); + } else if (pageSize === '1') { + queryParams = Object.assign(queryParams, {pageSize: '10'}); + } + const navigationExtras: NavigationExtras = { + queryParams: queryParams, + queryParamsHandling: 'merge' + }; - this.router.navigate([this.getSearchLink()], navigationExtras); + this.router.navigate(hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], navigationExtras); + }) } /** * @returns {string} The base path to the search page */ getSearchLink(): string { - const urlTree = this.router.parseUrl(this.router.url); - const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET]; - return '/' + g.toString(); + return '/search'; } /** diff --git a/src/app/+search-page/search-settings/search-settings.component.spec.ts b/src/app/+search-page/search-settings/search-settings.component.spec.ts index b1585c4347f3b385a67cf10f1478f0c63ae21c6b..b9b5c5a5ebd0a14a4ab35f989e45cce11fd03508 100644 --- a/src/app/+search-page/search-settings/search-settings.component.spec.ts +++ b/src/app/+search-page/search-settings/search-settings.component.spec.ts @@ -14,8 +14,8 @@ import { By } from '@angular/platform-browser'; import { SearchFilterService } from '../search-filters/search-filter/search-filter.service'; import { hot } from 'jasmine-marbles'; import { VarDirective } from '../../shared/utils/var.directive'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; import { first } from 'rxjs/operators'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; describe('SearchSettingsComponent', () => { @@ -73,7 +73,7 @@ describe('SearchSettingsComponent', () => { useValue: {} }, { - provide: SearchConfigurationService, + provide: SEARCH_CONFIG_SERVICE, useValue: { paginatedSearchOptions: hot('a', { a: paginatedSearchOptions diff --git a/src/app/+search-page/search-settings/search-settings.component.ts b/src/app/+search-page/search-settings/search-settings.component.ts index 7fc5645fcc59ed427508dc2d84856e973010b9be..aac861c64f409ada56385e9297ec4902b6ee9a52 100644 --- a/src/app/+search-page/search-settings/search-settings.component.ts +++ b/src/app/+search-page/search-settings/search-settings.component.ts @@ -1,10 +1,11 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { Observable } from 'rxjs'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; @Component({ selector: 'ds-search-settings', @@ -17,6 +18,11 @@ import { SearchConfigurationService } from '../search-service/search-configurati */ export class SearchSettingsComponent implements OnInit { + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * The configuration for the current paginated search results */ @@ -30,7 +36,7 @@ export class SearchSettingsComponent implements OnInit { constructor(private service: SearchService, private route: ActivatedRoute, private router: Router, - private searchConfigurationService: SearchConfigurationService) { + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigurationService: SearchConfigurationService) { } /** @@ -53,7 +59,7 @@ export class SearchSettingsComponent implements OnInit { }, queryParamsHandling: 'merge' }; - this.router.navigate([ '/search' ], navigationExtras); + this.router.navigate(this.getSearchLinkParts(), navigationExtras); } /** @@ -70,6 +76,26 @@ export class SearchSettingsComponent implements OnInit { }, queryParamsHandling: 'merge' }; - this.router.navigate([ '/search' ], navigationExtras); + this.router.navigate(this.getSearchLinkParts(), navigationExtras); + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.service.getSearchLink(); + } + + /** + * @returns {string[]} The base path to the search page, or the current page when inPlaceSearch is true, split in separate pieces + */ + public getSearchLinkParts(): string[] { + if (this.service) { + return []; + } + return this.getSearchLink().split('/'); } } diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.html b/src/app/+search-page/search-sidebar/search-sidebar.component.html index 5ff1e3c8faf90ab448df34e922e649d82b1c2ee6..50877052ec808f697e96bac46eaef56e4c68fd7f 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.html +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.html @@ -8,10 +8,11 @@ </button> </div> <div id="search-sidebar-content"> - <ds-view-mode-switch class="d-none d-md-block"></ds-view-mode-switch> + <ds-view-mode-switch [viewModeList]="viewModeList" class="d-none d-md-block"></ds-view-mode-switch> <div class="sidebar-content"> - <ds-search-filters></ds-search-filters> - <ds-search-settings></ds-search-settings> + <ds-search-switch-configuration *ngIf="configurationList" [configurationList]="configurationList"></ds-search-switch-configuration> + <ds-search-filters [inPlaceSearch]="inPlaceSearch"></ds-search-filters> + <ds-search-settings [inPlaceSearch]="inPlaceSearch"></ds-search-settings> </div> </div> </div> diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.scss b/src/app/+search-page/search-sidebar/search-sidebar.component.scss index b5bd6dd30d440cdc3ecdd389089cd329fa5e5f03..35ce5eebce06d2e00a011efae6d492ae660e7319 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.scss +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.scss @@ -8,8 +8,12 @@ ds-view-mode-switch { margin-bottom: $spacer; } - .sidebar-content > *:not(:last-child) { + .sidebar-content > *:not(:last-child):not(ds-search-switch-configuration) { margin-bottom: 4*$spacer; display: block; } + ds-search-switch-configuration { + margin-bottom: 2*$spacer; + display: block; + } } diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.ts b/src/app/+search-page/search-sidebar/search-sidebar.component.ts index 8b68cda793fe1c29475747eedebdb0c2bdaf55c0..9ee0a74942a5474a62e553a2046dbcfcd0712141 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.ts @@ -1,5 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model'; + /** * This component renders a simple item page. * The route parameter 'id' is used to request the item it represents. @@ -17,13 +19,29 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; */ export class SearchSidebarComponent { + /** + * The list of available configuration options + */ + @Input() configurationList: SearchConfigurationOption[]; + /** * The total amount of results */ @Input() resultCount; + /** + * The list of available view mode options + */ + @Input() viewModeList; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Emits event when the user clicks a button to open or close the sidebar */ @Output() toggleSidebar = new EventEmitter<boolean>(); + } diff --git a/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f9a72da4856fcc3d319235281069bdd1780e217 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts @@ -0,0 +1,15 @@ +/** + * Represents a search configuration select option + */ +export interface SearchConfigurationOption { + + /** + * The select option value + */ + value: string; + + /** + * The select option label + */ + label: string; +} diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html new file mode 100644 index 0000000000000000000000000000000000000000..8df37214d1d4b8c2ed1fed2ba7f5a7fec0427293 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html @@ -0,0 +1,13 @@ +<div *ngIf="configurationList?.length > 1" class="search-switch-configuration"> + <h5>{{ 'search.switch-configuration.title' | translate}}</h5> + + <select class="form-control" + [compareWith]="compare" + [(ngModel)]="selectedOption" + (change)="onSelect()"> + <option *ngFor="let option of configurationList;" [ngValue]="option.value"> + {{option.label | translate}} + </option> + </select> + +</div> diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3efc240e1232c4acb0ef7b61a6a5d62b7dc98fd --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts @@ -0,0 +1,109 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { SearchSwitchConfigurationComponent } from './search-switch-configuration.component'; +import { MYDSPACE_ROUTE, SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { NavigationExtras, Router } from '@angular/router'; +import { RouterStub } from '../../shared/testing/router-stub'; +import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; +import { SearchService } from '../search-service/search.service'; + +describe('SearchSwitchConfigurationComponent', () => { + + let comp: SearchSwitchConfigurationComponent; + let fixture: ComponentFixture<SearchSwitchConfigurationComponent>; + let searchConfService: SearchConfigurationServiceStub; + let select: any; + + const searchServiceStub = jasmine.createSpyObj('SearchService', { + getSearchLink: jasmine.createSpy('getSearchLink') + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ SearchSwitchConfigurationComponent ], + providers: [ + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchServiceStub }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + ], + schemas: [ NO_ERRORS_SCHEMA ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchSwitchConfigurationComponent); + comp = fixture.componentInstance; + searchConfService = TestBed.get(SEARCH_CONFIG_SERVICE); + + spyOn(searchConfService, 'getCurrentConfiguration').and.returnValue(observableOf(MyDSpaceConfigurationValueType.Workspace)); + + comp.configurationList = [ + { + value: MyDSpaceConfigurationValueType.Workspace, + label: 'workspace' + }, + { + value: MyDSpaceConfigurationValueType.Workflow, + label: 'workflow' + }, + ]; + + // SearchSwitchConfigurationComponent test instance + fixture.detectChanges(); + + }); + + it('should init the current configuration name', () => { + expect(comp.selectedOption).toBe(MyDSpaceConfigurationValueType.Workspace); + }); + + it('should display select field properly', () => { + const selectField = fixture.debugElement.query(By.css('.form-control')); + expect(selectField).toBeDefined(); + + const childElements = selectField.children; + expect(childElements.length).toEqual(comp.configurationList.length); + }); + + it('should call onSelect method when selecting an option', () => { + fixture.whenStable().then(() => { + spyOn(comp, 'onSelect'); + select = fixture.debugElement.query(By.css('select')); + const selectEl = select.nativeElement; + selectEl.value = selectEl.options[1].value; // <-- select a new value + selectEl.dispatchEvent(new Event('change')); + fixture.detectChanges(); + expect(comp.onSelect).toHaveBeenCalled(); + }); + + }); + + it('should navigate to the route when selecting an option', () => { + (comp as any).searchService.getSearchLink.and.returnValue(MYDSPACE_ROUTE); + comp.selectedOption = MyDSpaceConfigurationValueType.Workflow; + const navigationExtras: NavigationExtras = { + queryParams: {configuration: MyDSpaceConfigurationValueType.Workflow}, + }; + + fixture.detectChanges(); + + comp.onSelect(); + + expect((comp as any).router.navigate).toHaveBeenCalledWith([MYDSPACE_ROUTE], navigationExtras); + }); +}); diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..c34fe2030358b431f78c90eaf5c8b8c0ac1f5ee2 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts @@ -0,0 +1,80 @@ +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { NavigationExtras, Router } from '@angular/router'; + +import { Subscription } from 'rxjs'; + +import { hasValue } from '../../shared/empty.util'; +import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; +import { SearchConfigurationOption } from './search-configuration-option.model'; +import { SearchService } from '../search-service/search.service'; + +@Component({ + selector: 'ds-search-switch-configuration', + styleUrls: ['./search-switch-configuration.component.scss'], + templateUrl: './search-switch-configuration.component.html', +}) +/** + * Represents a select that allow to switch over available search configurations + */ +export class SearchSwitchConfigurationComponent implements OnDestroy, OnInit { + + /** + * The list of available configuration options + */ + @Input() configurationList: SearchConfigurationOption[] = []; + + /** + * The selected option + */ + public selectedOption: string; + + /** + * Subscription to unsubscribe from + */ + private sub: Subscription; + + constructor(private router: Router, + private searchService: SearchService, + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { + } + + /** + * Init current configuration + */ + ngOnInit() { + this.searchConfigService.getCurrentConfiguration('default') + .subscribe((currentConfiguration) => this.selectedOption = currentConfiguration); + } + + /** + * Init current configuration + */ + onSelect() { + const navigationExtras: NavigationExtras = { + queryParams: {configuration: this.selectedOption}, + }; + + this.router.navigate([this.searchService.getSearchLink()], navigationExtras); + } + + /** + * Define the select 'compareWith' method to tell Angular how to compare the values + * + * @param item1 + * @param item2 + */ + compare(item1: MyDSpaceConfigurationValueType, item2: MyDSpaceConfigurationValueType) { + return item1 === item2; + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy() { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 266839f64babbe2c466ed024e87410ddee0579dc..86364aca895d831432b60da2d21137da9e20180a 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -24,6 +24,7 @@ export function getCommunityModulePath() { { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, + { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, { path: 'admin', loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 14784d1b55f496dd181e1e9b4dec9301ea278c6d..cbabe5c3fd406063b07365bc761d1c35781ffb23 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -6,18 +6,10 @@ 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, - GetRequest, - PostRequest, - RestRequest -} from '../data/request.models'; +import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models'; import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RequestEntry } from '../data/request.reducer'; import { getResponseFromEntry } from '../shared/operators'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @Injectable() export class AuthRequestService { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7874eebea892b581f6f7ca364c0b5cf184852a0e..a01768e687cd7719b53faf7885a313903085b293 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,43 +1,27 @@ -import {Observable, of, of as observableOf} from 'rxjs'; -import { - distinctUntilChanged, - filter, - first, - map, - startWith, - switchMap, - take, - withLatestFrom -} from 'rxjs/operators'; import { Inject, Injectable, Optional } from '@angular/core'; import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { RouterReducerState } from '@ngrx/router-store'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; import { EPerson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; - import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../../shared/services/cookie.service'; -import { - getAuthenticationToken, - getRedirectUrl, - isAuthenticated, - isTokenRefreshing -} from './selectors'; +import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index fa637981ae585d30e3aadd262e1dd80c3f04b0f8..8c88e0fce55c98278971436baa28ec678472b0d6 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -8,6 +8,7 @@ import { createSelector } from '@ngrx/store'; */ import { AuthState } from './auth.reducer'; import { AppState } from '../../app.reducer'; +import { EPerson } from '../eperson/models/eperson.model'; /** * Returns the user state. @@ -35,11 +36,12 @@ const _isAuthenticatedLoaded = (state: AuthState) => state.loaded; /** * Return the users state + * NOTE: when state is REHYDRATED user object lose prototype so return always a new EPerson object * @function _getAuthenticatedUser * @param {State} state - * @returns {User} + * @returns {EPerson} */ -const _getAuthenticatedUser = (state: AuthState) => state.user; +const _getAuthenticatedUser = (state: AuthState) => Object.assign(new EPerson(), state.user); /** * Returns the authentication error. diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 6b8034bafd1996a0505206456b501a46dbe61949..8c706d46cdd1de5cecd695564f4b65552a04b7c8 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs'; -import { distinctUntilChanged, flatMap, map, startWith, switchMap } from 'rxjs/operators'; +import { distinctUntilChanged, flatMap, map, startWith, switchMap, tap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; import { PaginatedList } from '../../data/paginated-list'; @@ -205,17 +205,28 @@ export class RemoteDataBuildService { return observableCombineLatest(...input).pipe( map((arr) => { + // The request of an aggregate RD should be pending if at least one + // of the RDs it's based on is still in the state RequestPending const requestPending: boolean = arr .map((d: RemoteData<T>) => d.isRequestPending) - .every((b: boolean) => b === true); + .find((b: boolean) => b === true); - const responsePending: boolean = arr + // The response of an aggregate RD should be pending if no requests + // are still pending and at least one of the RDs it's based + // on is still in the state ResponsePending + const responsePending: boolean = !requestPending && arr .map((d: RemoteData<T>) => d.isResponsePending) - .every((b: boolean) => b === true); + .find((b: boolean) => b === true); - const isSuccessful: boolean = arr - .map((d: RemoteData<T>) => d.hasSucceeded) - .every((b: boolean) => b === true); + let isSuccessful: boolean; + // isSuccessful should be undefined until all responses have come in. + // We can't know its state beforehand. We also can't say it's false + // because that would imply a request failed. + if (!(requestPending || responsePending)) { + isSuccessful = arr + .map((d: RemoteData<T>) => d.hasSucceeded) + .every((b: boolean) => b === true); + } const errorMessage: string = arr .map((d: RemoteData<T>) => d.error) diff --git a/src/app/core/cache/models/items/normalized-item-type.model.ts b/src/app/core/cache/models/items/normalized-item-type.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed38d80a4baf8332743baa8e797119d968cfd254 --- /dev/null +++ b/src/app/core/cache/models/items/normalized-item-type.model.ts @@ -0,0 +1,32 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { ItemType } from '../../../shared/item-relationships/item-type.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo } from '../../builders/build-decorators'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace ItemType + */ +@mapsTo(ItemType) +@inheritSerialization(NormalizedObject) +export class NormalizedItemType extends NormalizedObject<ItemType> { + + /** + * The label that describes the ResourceType of the Item + */ + @autoserialize + label: string; + + /** + * The identifier of this ItemType + */ + @autoserialize + id: string; + + /** + * The universally unique identifier of this ItemType + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.ItemType), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/items/normalized-relationship-type.model.ts b/src/app/core/cache/models/items/normalized-relationship-type.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..d201fb27462d861db0eee1ec793c2b6ff27ed6dc --- /dev/null +++ b/src/app/core/cache/models/items/normalized-relationship-type.model.ts @@ -0,0 +1,77 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { RelationshipType } from '../../../shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo, relationship } from '../../builders/build-decorators'; +import { NormalizedDSpaceObject } from '../normalized-dspace-object.model'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace RelationshipType + */ +@mapsTo(RelationshipType) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedRelationshipType extends NormalizedObject<RelationshipType> { + + /** + * The identifier of this RelationshipType + */ + @autoserialize + id: string; + + /** + * The label that describes the Relation to the left of this RelationshipType + */ + @autoserialize + leftLabel: string; + + /** + * The maximum amount of Relationships allowed to the left of this RelationshipType + */ + @autoserialize + leftMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the left of this RelationshipType + */ + @autoserialize + leftMinCardinality: number; + + /** + * The label that describes the Relation to the right of this RelationshipType + */ + @autoserialize + rightLabel: string; + + /** + * The maximum amount of Relationships allowed to the right of this RelationshipType + */ + @autoserialize + rightMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the right of this RelationshipType + */ + @autoserialize + rightMinCardinality: number; + + /** + * The type of Item found to the left of this RelationshipType + */ + @autoserialize + @relationship(ResourceType.ItemType, false) + leftType: string; + + /** + * The type of Item found to the right of this RelationshipType + */ + @autoserialize + @relationship(ResourceType.ItemType, false) + rightType: string; + + /** + * The universally unique identifier of this RelationshipType + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.RelationshipType), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/items/normalized-relationship.model.ts b/src/app/core/cache/models/items/normalized-relationship.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9084263612b0805bbf1f3763e8dd0f62f7e9968 --- /dev/null +++ b/src/app/core/cache/models/items/normalized-relationship.model.ts @@ -0,0 +1,57 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { Relationship } from '../../../shared/item-relationships/relationship.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo, relationship } from '../../builders/build-decorators'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace Relationship + */ +@mapsTo(Relationship) +@inheritSerialization(NormalizedObject) +export class NormalizedRelationship extends NormalizedObject<Relationship> { + + /** + * The identifier of this Relationship + */ + @autoserialize + id: string; + + /** + * The identifier of the Item to the left side of this Relationship + */ + @autoserialize + leftId: string; + + /** + * The identifier of the Item to the right side of this Relationship + */ + @autoserialize + rightId: string; + + /** + * The place of the Item to the left side of this Relationship + */ + @autoserialize + leftPlace: number; + + /** + * The place of the Item to the right side of this Relationship + */ + @autoserialize + rightPlace: number; + + /** + * The type of Relationship + */ + @autoserialize + @relationship(ResourceType.RelationshipType, false) + relationshipType: string; + + /** + * The universally unique identifier of this Relationship + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.Relationship), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts index c5be6d8e2eb11876c223b5dbc30b73837799fa19..0ce2874f0bc3307c99e26e874caf1e91aad341d5 100644 --- a/src/app/core/cache/models/normalized-item.model.ts +++ b/src/app/core/cache/models/normalized-item.model.ts @@ -65,4 +65,8 @@ export class NormalizedItem extends NormalizedDSpaceObject<Item> { @relationship(ResourceType.Bitstream, true) bitstreams: string[]; + @autoserialize + @relationship(ResourceType.Relationship, true) + relationships: string[]; + } diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index eae7c06be7e20ba528eff53f8dc5b457ae8e25bd..20e12108ad91bf394da0393d2e29caa856272739 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,18 +1,19 @@ +import * as ngrx from '@ngrx/store'; import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { ObjectCacheService } from './object-cache.service'; import { AddPatchObjectCacheAction, - AddToObjectCacheAction, ApplyPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import { CoreState } from '../core.reducers'; import { ResourceType } from '../shared/resource-type'; import { NormalizedItem } from './models/normalized-item.model'; import { first } from 'rxjs/operators'; -import * as ngrx from '@ngrx/store'; -import { Operation } from '../../../../node_modules/fast-json-patch'; +import { Operation } from 'fast-json-patch'; import { RestRequestMethod } from '../data/rest-request-method'; import { AddToSSBAction } from './server-sync-buffer.actions'; import { Patch } from './object-cache.reducer'; diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 753f9dab1ea17763252c04560f5bcf86e06d70d0..2f652d91a2114645a418e81f53e8ab42859950d5 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -8,6 +8,8 @@ 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 { 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'; @@ -254,4 +256,38 @@ export class EpersonSuccessResponse extends RestResponse { } } +export class MessageResponse extends RestResponse { + public toCache = false; + + constructor( + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class TaskResponse extends RestResponse { + public toCache = false; + + constructor( + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class FilteredDiscoveryQueryResponse extends RestResponse { + constructor( + public filterQuery: string, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index ef6f508226bfb7301f47eb1847abfb8032024096..d4bda00a349be5bd656e6c89117bb74d366ed4a8 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -71,6 +71,7 @@ import { SubmissionRestService } from './submission/submission-rest.service'; import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; +import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { MenuService } from '../shared/menu/menu.service'; import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; @@ -100,6 +101,12 @@ import { NormalizedSubmissionSectionModel } from './config/models/normalized-con import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model'; import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model'; import { BrowseEntry } from './shared/browse-entry.model'; +import { RoleService } from './roles/role.service'; +import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; +import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; +import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; +import { PoolTaskDataService } from './tasks/pool-task-data.service'; +import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; const IMPORTS = [ CommonModule, @@ -147,6 +154,7 @@ const PROVIDERS = [ RegistryBitstreamformatsResponseParsingService, DebugResponseParsingService, SearchResponseParsingService, + MyDSpaceResponseParsingService, ServerResponseService, BrowseResponseParsingService, BrowseEntriesResponseParsingService, @@ -178,6 +186,11 @@ const PROVIDERS = [ MenuService, ObjectUpdatesService, SearchService, + MyDSpaceGuard, + RoleService, + TaskResponseParsingService, + ClaimedTaskDataService, + PoolTaskDataService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -185,7 +198,8 @@ const PROVIDERS = [ multi: true }, NotificationsService, - { provide: NativeWindowService, useFactory: NativeWindowFactory }, + FilteredDiscoveryPageResponseParsingService, + { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; /** diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 15f52e0939403ee3b7757f281e6805496a7feee5..cae59994b5992637f5bf9399b7cd5e460d30ffe2 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -6,8 +6,6 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList } from './paginated-list'; -import { ResourceType } from '../shared/resource-type'; -import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { isRestDataObject, isRestPaginatedList } from '../cache/builders/normalized-object-build.service'; import { getNormalizedConstructorByType } from '../shared/resource-type.decorator'; /* tslint:disable:max-classes-per-file */ @@ -66,7 +64,9 @@ export abstract class BaseResponseParsingService { let list = data._embedded; // Workaround for inconsistency in rest response. Issue: https://github.com/DSpace/dspace-angular/issues/238 - if (!Array.isArray(list)) { + if (hasNoValue(list)) { + list = []; + } else if (!Array.isArray(list)) { list = this.flattenSingleKeyObject(list); } const page: ObjectDomain[] = this.processArray(list, requestUUID); @@ -140,4 +140,8 @@ export abstract class BaseResponseParsingService { protected retrieveObjectOrUrl(obj: any): any { return this.toCache ? obj.self : obj; } + + protected isSuccessStatus(statusCode: number) { + return statusCode >= 200 && statusCode < 300; + } } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 3d03b9397df9c28d3d9f7598463569a0df472eef..993954a360ae6d4c265722e99229dc518f34b77e 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@angular/core'; + +import { filter, map, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedCollection } from '../cache/models/normalized-collection.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { Collection } from '../shared/collection.model'; @@ -13,6 +15,10 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { FindAllOptions } from './request.models'; +import { RemoteData } from './remote-data'; +import { PaginatedList } from './paginated-list'; @Injectable() export class CollectionDataService extends ComColDataService<Collection> { @@ -34,4 +40,21 @@ export class CollectionDataService extends ComColDataService<Collection> { super(); } + /** + * Find whether there is a collection whom user has authorization to submit to + * + * @return boolean + * true if the user has at least one collection to submit to + */ + hasAuthorizedCollection(): Observable<boolean> { + const searchHref = 'findAuthorized'; + const options = new FindAllOptions(); + options.elementsPerPage = 1; + + return this.searchBy(searchHref, options).pipe( + filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending), + take(1), + map((collections: RemoteData<PaginatedList<Collection>>) => collections.payload.totalElements > 0) + ); + } } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 75ef58b06bb8c7c47a461813dd1cd3b64fbc52e7..8db4d762eb69fa104ff85d3a5e5839893d688aeb 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,9 +1,8 @@ -import { filter, mergeMap, take } from 'rxjs/operators'; +import { filter, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; @@ -12,7 +11,7 @@ import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions, FindAllRequest } from './request.models'; import { RemoteData } from './remote-data'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue } from '../../shared/empty.util'; import { Observable } from 'rxjs'; import { PaginatedList } from './paginated-list'; import { NotificationsService } from '../../shared/notifications/notifications.service'; diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 4a244db24f2d666fd0553be25780ac8124febc41..dede6f8ae2975bc7dea177e7e45134dc578e0c98 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -9,13 +9,12 @@ import { Observable, of as observableOf } from 'rxjs'; import { FindAllOptions } from './request.models'; import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { Operation } from '../../../../node_modules/fast-json-patch'; +import { compare, Operation } from 'fast-json-patch'; import { DSpaceObject } from '../shared/dspace-object.model'; import { ChangeAnalyzer } from './change-analyzer'; import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { compare } from 'fast-json-patch'; import { Item } from '../shared/item.model'; const endpoint = 'https://rest.api/core'; diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index 1fd207d2bffa0a16741bb049d8ed0876942eee72..cd30479f6df00ea59e537d967a3259fc638b1f24 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -1,9 +1,7 @@ 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'; diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d81ce4b6bd2bd335065c9071ae29a2fbf1dc0753 --- /dev/null +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts @@ -0,0 +1,36 @@ +import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; +import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { ResponseParsingService } from './parsing.service'; +import { GetRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; + +describe('FilteredDiscoveryPageResponseParsingService', () => { + let service: FilteredDiscoveryPageResponseParsingService; + + beforeEach(() => { + service = new FilteredDiscoveryPageResponseParsingService(undefined, getMockObjectCacheService()); + }); + + describe('parse', () => { + const request = Object.assign(new GetRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/path'), { + getResponseParser(): GenericConstructor<ResponseParsingService> { + return FilteredDiscoveryPageResponseParsingService; + } + }); + + const mockResponse = { + payload: { + 'discovery-query': 'query' + }, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + + it('should return a FilteredDiscoveryQueryResponse containing the correct query', () => { + const response = service.parse(request, mockResponse); + expect((response as FilteredDiscoveryQueryResponse).filterQuery).toBe(mockResponse.payload['discovery-query']); + }) + }); +}); diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..166a915b162fe6a173c95bd80636b6752fc80f5c --- /dev/null +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@angular/core'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../config'; +import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models'; + +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a discovery query (string) + * wrapped in a FilteredDiscoveryQueryResponse + */ +@Injectable() +export class FilteredDiscoveryPageResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + objectFactory = {}; + toCache = false; + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { super(); + } + + /** + * Parses data from the REST API to a discovery query wrapped in a FilteredDiscoveryQueryResponse + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const query = data.payload['discovery-query']; + return new FilteredDiscoveryQueryResponse(query, data.statusCode, data.statusText); + } +} diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6945e27b40aa2b8d711848e75ee720f2e992d75 --- /dev/null +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; +import { RestResponse, SearchSuccessResponse } from '../cache/response.models'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { hasValue } from '../../shared/empty.util'; +import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; +import { MetadataMap, MetadataValue } from '../shared/metadata.models'; + +@Injectable() +export class MyDSpaceResponseParsingService implements ResponseParsingService { + constructor(private dsoParser: DSOResponseParsingService) { + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + // fallback for unexpected empty response + const emptyPayload = { + _embedded: { + objects: [] + } + }; + const payload = data.payload._embedded.searchResult || emptyPayload; + const hitHighlights: MetadataMap[] = payload._embedded.objects + .map((object) => object.hitHighlights) + .map((hhObject) => { + const mdMap: MetadataMap = {}; + if (hhObject) { + for (const key of Object.keys(hhObject)) { + const value: MetadataValue = Object.assign(new MetadataValue(), { + value: hhObject[key].join('...'), + language: null + }); + mdMap[key] = [value]; + } + } + return mdMap; + }); + + const dsoSelfLinks = payload._embedded.objects + .filter((object) => hasValue(object._embedded)) + .map((object) => object._embedded.indexableObject) + .map((dso) => this.dsoParser.parse(request, { + payload: dso, + statusCode: data.statusCode, + statusText: data.statusText + })) + .map((obj) => obj.resourceSelfLinks) + .reduce((combined, thisElement) => [...combined, ...thisElement], []); + + const objects = payload._embedded.objects + .filter((object) => hasValue(object._embedded)) + .map((object, index) => Object.assign({}, object, { + indexableObject: dsoSelfLinks[index], + hitHighlights: hitHighlights[index], + _embedded: this.filterEmbeddedObjects(object) + })); + payload.objects = objects; + const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload); + return new SearchSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(payload)); + } + + protected filterEmbeddedObjects(object) { + const allowedEmbeddedKeys = ['submitter', 'item', 'workspaceitem', 'workflowitem']; + if (object._embedded.indexableObject && object._embedded.indexableObject._embedded) { + return Object.assign({}, object._embedded, { + indexableObject: Object.assign({}, object._embedded.indexableObject, { + _embedded: Object.keys(object._embedded.indexableObject._embedded) + .filter((key) => allowedEmbeddedKeys.includes(key)) + .reduce((obj, key) => { + obj[key] = object._embedded.indexableObject._embedded[key]; + return obj; + }, {}) + }) + }); + } else { + return object; + } + + } +} diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index a13fb9487b2020a3bca756180323f8696751230b..22d5fd3e774a3047827e285ceb0053049cf356ce 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -5,7 +5,8 @@ import { coreSelector } from '../../core.selectors'; import { FieldState, FieldUpdates, - Identifiable, OBJECT_UPDATES_TRASH_PATH, + Identifiable, + OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, ObjectUpdatesState } from './object-updates.reducer'; @@ -17,9 +18,10 @@ import { InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - SetEditableFieldUpdateAction, SetValidFieldUpdateAction + SetEditableFieldUpdateAction, + SetValidFieldUpdateAction } from './object-updates.actions'; -import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; @@ -212,6 +214,7 @@ export class ObjectUpdatesService { /** * Method to dispatch an RemoveFieldUpdateAction to the store * @param url The page's URL for which the changes should be removed + * @param uuid The UUID of the field that should be set */ removeSingleFieldUpdate(url: string, uuid) { this.store.dispatch(new RemoveFieldUpdateAction(url, uuid)); diff --git a/src/app/core/data/paginated-list.ts b/src/app/core/data/paginated-list.ts index faecd231bc99f1e112ec2c6b0a91a3e6f9011d1a..e1c1b22569b752593030e4f28a45f25f6323ac7a 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 { hasNoValue, hasValue } from '../../shared/empty.util'; +import { hasValue } from '../../shared/empty.util'; export class PaginatedList<T> { @@ -11,7 +11,7 @@ export class PaginatedList<T> { if (hasValue(this.pageInfo) && hasValue(this.pageInfo.elementsPerPage)) { return this.pageInfo.elementsPerPage; } - return this.page.length; + return this.getPageLength(); } set elementsPerPage(value: number) { @@ -22,10 +22,7 @@ export class PaginatedList<T> { if (hasValue(this.pageInfo) && hasValue(this.pageInfo.totalElements)) { return this.pageInfo.totalElements; } - if (hasNoValue(this.page)) { - return 0; - } - return this.page.length; + return this.getPageLength(); } set totalElements(value: number) { @@ -92,4 +89,8 @@ export class PaginatedList<T> { set self(self: string) { this.pageInfo.self = self; } + + protected getPageLength() { + return (Array.isArray(this.page)) ? this.page.length : 0; + } } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index d2cdd45a0a66767a654ebd50ea7470609b9a68f5..a2b34239609ee15ea33d0cdc00a485357047b2af 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -14,10 +14,10 @@ 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'; +import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -371,6 +371,30 @@ export class DeleteByIDRequest extends DeleteRequest { } } +export class TaskPostRequest extends PostRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor<ResponseParsingService> { + return TaskResponseParsingService; + } +} + +export class TaskDeleteRequest extends DeleteRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor<ResponseParsingService> { + return TaskResponseParsingService; + } +} + +export class MyDSpaceRequest extends GetRequest { + public responseMsToLive = 0; +} + export class RequestError extends Error { statusCode: number; statusText: string; diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 9f79d7539bff8bbeb3c91eb2dee26f175c7f7897..83071382ed91981366b46b429b5d8dd9f8d05950 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,11 +1,13 @@ import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { Observable, race as observableRace } from 'rxjs'; -import { filter, map, mergeMap, take } from 'rxjs/operators'; +import { filter, find, map, mergeMap, take } from 'rxjs/operators'; +import { cloneDeep, remove } from 'lodash'; import { AppState } from '../../app.reducer'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; @@ -23,8 +25,6 @@ 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 { HttpHeaders } from '@angular/common/http'; -import { cloneDeep } from 'lodash'; /** * The base selector function to select the request state in the store @@ -49,7 +49,6 @@ const entryFromUUIDSelector = (uuid: string): MemoizedSelector<CoreState, Reques * 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 = @@ -149,12 +148,14 @@ export class RequestService { * @param {RestRequest} request The request to send out * @param {boolean} forceBypassCache When true, a new request is always dispatched */ - // TODO to review "forceBypassCache" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed configure<T extends CacheableObject>(request: RestRequest, forceBypassCache: boolean = false): void { const isGetRequest = request.method === RestRequestMethod.GET; - if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { + if (forceBypassCache) { + this.clearRequestsOnTheirWayToTheStore(request); + } + if (!isGetRequest || (forceBypassCache && !this.isPending(request)) || !this.isCachedOrPending(request)) { this.dispatchRequest(request); - if (isGetRequest && !forceBypassCache) { + if (isGetRequest) { this.trackRequestsOnTheirWayToTheStore(request); } } else { @@ -168,6 +169,29 @@ export class RequestService { } } + /** + * Convert request Payload to a URL-encoded string + * + * e.g. uriEncodeBody({param: value, param1: value1}) + * returns: param=value¶m1=value1 + * + * @param body + * The request Payload to convert + * @return string + * URL-encoded string + */ + public uriEncodeBody(body: any) { + let queryParams = ''; + if (isNotEmpty(body) && typeof body === 'object') { + Object.keys(body) + .forEach((param) => { + const paramValue = `${param}=${body[param]}`; + queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue); + }) + } + return encodeURI(queryParams); + } + /** * Remove all request cache providing (part of) the href * This also includes href-to-uuid index cache @@ -234,6 +258,19 @@ export class RequestService { }); } + /** + * This method remove requests that are on their way to the store. + */ + private clearRequestsOnTheirWayToTheStore(request: GetRequest) { + this.getByHref(request.href).pipe( + find((re: RequestEntry) => hasValue(re))) + .subscribe((re: RequestEntry) => { + if (!re.responsePending) { + remove(this.requestsOnTheirWayToTheStore, (item) => item === request.href); + } + }); + } + /** * Dispatch commit action to send all changes (for a certain method) to the server (buffer) * @param {RestRequestMethod} method RestRequestMethod for which the changes should be committed diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 0ca793c5aed9ac7fed8dcdb9498c2363e84bdf4b..9ab01043930ccedf09cf14e9be2926391b9e9349 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -15,7 +15,13 @@ export class SearchResponseParsingService implements ResponseParsingService { } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.payload._embedded.searchResult; + // fallback for unexpected empty response + const emptyPayload = { + _embedded : { + objects: [] + } + }; + const payload = data.payload._embedded.searchResult || emptyPayload; const hitHighlights: MetadataMap[] = payload._embedded.objects .map((object) => object.hitHighlights) .map((hhObject) => { @@ -31,7 +37,7 @@ export class SearchResponseParsingService implements ResponseParsingService { const dsoSelfLinks = payload._embedded.objects .filter((object) => hasValue(object._embedded)) - .map((object) => object._embedded.dspaceObject) + .map((object) => object._embedded.indexableObject) // we don't need embedded collections, bitstreamformats, etc for search results. // And parsing them all takes up a lot of time. Throw them away to improve performance // until objs until partial results are supported by the rest api @@ -47,7 +53,7 @@ export class SearchResponseParsingService implements ResponseParsingService { const objects = payload._embedded.objects .filter((object) => hasValue(object._embedded)) .map((object, index) => Object.assign({}, object, { - dspaceObject: dsoSelfLinks[index], + indexableObject: dsoSelfLinks[index], hitHighlights: hitHighlights[index], // we don't need embedded collections, bitstreamformats, etc for search results. // And parsing them all takes up a lot of time. Throw them away to improve performance 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 204c782e796756b877bc8e48559bca7274dcf965..290f4be8a2324efad4486d7ad1923edd62c1cf1f 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 @@ -68,6 +68,8 @@ export class DSpaceRESTv2Service { * the URL for the request * @param body * an optional body for the request + * @param options + * the HttpOptions object * @return {Observable<string>} * An Observable<string> containing the response from the server */ diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index 32286929ee3eff207f8478e6792d503985a37b15..f8c11c120157489911cd8492a29e455f63dd808d 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -47,7 +47,9 @@ export class EPerson extends DSpaceObject { */ public selfRegistered: boolean; - /** Getter to retrieve the EPerson's full name as a string */ + /** + * 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/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index a95fc73d33fa56b5a7bcfca7f774043031b32273..309dfd88907bcde3651d84210f1f3b9c74c1a8b7 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -59,7 +59,7 @@ export class MetadataService { map((route: ActivatedRoute) => { route = this.getCurrentRoute(route); return { params: route.params, data: route.data }; - }),).subscribe((routeInfo: any) => { + })).subscribe((routeInfo: any) => { this.processRouteChange(routeInfo); }); } diff --git a/src/app/core/roles/role-types.ts b/src/app/core/roles/role-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..b39d1205a6d8536917e445ec361cdb86fa22ff89 --- /dev/null +++ b/src/app/core/roles/role-types.ts @@ -0,0 +1,5 @@ +export enum RoleType { + Submitter = 'submitter', + Controller = 'controller', + Admin = 'admin' +} diff --git a/src/app/core/roles/role.service.ts b/src/app/core/roles/role.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a4b6e6ccf91c43f3b3c8101f97f839146b3b5c9 --- /dev/null +++ b/src/app/core/roles/role.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; + +import { RoleType } from './role-types'; +import { CollectionDataService } from '../data/collection-data.service'; + +/** + * A service that provides methods to identify user role. + */ +@Injectable() +export class RoleService { + + /** + * Initialize instance variables + * + * @param {CollectionDataService} collectionService + */ + constructor(private collectionService: CollectionDataService) { + } + + /** + * Check if current user is a submitter + */ + isSubmitter(): Observable<boolean> { + return this.collectionService.hasAuthorizedCollection().pipe( + distinctUntilChanged() + ); + } + + /** + * Check if current user is a controller + */ + isController(): Observable<boolean> { + // TODO find a way to check if user is a controller + return observableOf(true); + } + + /** + * Check if current user is an admin + */ + isAdmin(): Observable<boolean> { + // TODO find a way to check if user is an admin + return observableOf(false); + } + + /** + * Check if current user by role type + * + * @param {RoleType} role + * the role type + */ + checkRole(role: RoleType): Observable<boolean> { + let check: Observable<boolean>; + switch (role) { + case RoleType.Submitter: + check = this.isSubmitter(); + break; + case RoleType.Controller: + check = this.isController(); + break; + case RoleType.Admin: + check = this.isAdmin(); + break; + } + + return check; + } +} diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 4aa0ba9695b94925a1b0a9e5474440407036cb14..fb42edd1260ae3fcbbc818a0566bfc117f32e626 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -7,6 +7,7 @@ import { CacheableObject, TypedObject } 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 { hasNoValue } from '../../shared/empty.util'; /** * An abstract model class for a DSpaceObject. @@ -123,4 +124,23 @@ export class DSpaceObject implements CacheableObject, ListableObject { return Metadata.has(this.metadata, keyOrKeys, valueFilter); } + /** + * Find metadata on a specific field and order all of them using their "place" property. + * @param key + */ + findMetadataSortedByPlace(key: string): MetadataValue[] { + return this.allMetadata([key]).sort((a: MetadataValue, b: MetadataValue) => { + if (hasNoValue(a.place) && hasNoValue(b.place)) { + return 0; + } + if (hasNoValue(a.place)) { + return -1; + } + if (hasNoValue(b.place)) { + return 1; + } + return a.place - b.place; + }); + } + } diff --git a/src/app/core/shared/item-relationships/item-type.model.ts b/src/app/core/shared/item-relationships/item-type.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4f98ab653071f3f774c769bb6ce4d8214ded0df --- /dev/null +++ b/src/app/core/shared/item-relationships/item-type.model.ts @@ -0,0 +1,27 @@ +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../resource-type'; + +/** + * Describes a type of Item + */ +export class ItemType implements CacheableObject { + /** + * The identifier of this ItemType + */ + id: string; + + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The universally unique identifier of this ItemType + */ + uuid: string; +} diff --git a/src/app/core/shared/item-relationships/relationship-type.model.ts b/src/app/core/shared/item-relationships/relationship-type.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..404d8cdb4b50e727efd17c065cd29b8c9dc4381c --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship-type.model.ts @@ -0,0 +1,75 @@ +import { Observable } from 'rxjs'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { RemoteData } from '../../data/remote-data'; +import { ResourceType } from '../resource-type'; +import { ItemType } from './item-type.model'; + +/** + * Describes a type of Relationship between multiple possible Items + */ +export class RelationshipType implements CacheableObject { + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The label that describes this RelationshipType + */ + label: string; + + /** + * The identifier of this RelationshipType + */ + id: string; + + /** + * The universally unique identifier of this RelationshipType + */ + uuid: string; + + /** + * The label that describes the Relation to the left of this RelationshipType + */ + leftLabel: string; + + /** + * The maximum amount of Relationships allowed to the left of this RelationshipType + */ + leftMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the left of this RelationshipType + */ + leftMinCardinality: number; + + /** + * The label that describes the Relation to the right of this RelationshipType + */ + rightLabel: string; + + /** + * The maximum amount of Relationships allowed to the right of this RelationshipType + */ + rightMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the right of this RelationshipType + */ + rightMinCardinality: number; + + /** + * The type of Item found to the left of this RelationshipType + */ + leftType: Observable<RemoteData<ItemType>>; + + /** + * The type of Item found to the right of this RelationshipType + */ + rightType: Observable<RemoteData<ItemType>>; +} diff --git a/src/app/core/shared/item-relationships/relationship.model.ts b/src/app/core/shared/item-relationships/relationship.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..df8f04cd8a8afee39a78646ff366f0800af88705 --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship.model.ts @@ -0,0 +1,55 @@ +import { Observable } from 'rxjs'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { RemoteData } from '../../data/remote-data'; +import { ResourceType } from '../resource-type'; +import { RelationshipType } from './relationship-type.model'; + +/** + * Describes a Relationship between two Items + */ +export class Relationship implements CacheableObject { + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The universally unique identifier of this Relationship + */ + uuid: string; + + /** + * The identifier of this Relationship + */ + id: string; + + /** + * The identifier of the Item to the left side of this Relationship + */ + leftId: string; + + /** + * The identifier of the Item to the right side of this Relationship + */ + rightId: string; + + /** + * The place of the Item to the left side of this Relationship + */ + leftPlace: number; + + /** + * The place of the Item to the right side of this Relationship + */ + rightPlace: number; + + /** + * The type of Relationship + */ + relationshipType: Observable<RemoteData<RelationshipType>>; +} diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 6cd5634fd053d06cc6808121694cebdadcd7aebf..839103b9f599d8d3bf2d25077c393bedf871dca3 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -5,8 +5,9 @@ import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; import { RemoteData } from '../data/remote-data'; import { Bitstream } from './bitstream.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { PaginatedList } from '../data/paginated-list'; +import { Relationship } from './item-relationships/relationship.model'; export class Item extends DSpaceObject { @@ -51,6 +52,8 @@ export class Item extends DSpaceObject { bitstreams: Observable<RemoteData<PaginatedList<Bitstream>>>; + relationships: Observable<RemoteData<PaginatedList<Relationship>>>; + /** * Retrieves the thumbnail of this item * @returns {Observable<Bitstream>} the primaryBitstream of the 'THUMBNAIL' bundle @@ -87,10 +90,12 @@ export class Item extends DSpaceObject { * Retrieves bitstreams by bundle name * @param bundleName The name of the Bundle that should be returned * @returns {Observable<Bitstream[]>} the bitstreams with the given bundleName + * TODO now that bitstreams can be paginated this should move to the server + * see https://github.com/DSpace/dspace-angular/issues/332 */ getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> { return this.bitstreams.pipe( - filter((rd: RemoteData<PaginatedList<Bitstream>>) => !rd.isResponsePending), + filter((rd: RemoteData<PaginatedList<Bitstream>>) => !rd.isResponsePending && isNotUndefined(rd.payload)), map((rd: RemoteData<PaginatedList<Bitstream>>) => rd.payload.page), filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), take(1), diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f31f8617adff4a81bf8728f38874407f8c8f0a04 --- /dev/null +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts @@ -0,0 +1,43 @@ +import { MetadataRepresentationType } from '../metadata-representation.model'; +import { ItemMetadataRepresentation, ItemTypeToValue } from './item-metadata-representation.model'; +import { Item } from '../../item.model'; +import { MetadataMap, MetadataValue } from '../../metadata.models'; + +describe('ItemMetadataRepresentation', () => { + const valuePrefix = 'Test value for '; + const item = new Item(); + let itemMetadataRepresentation: ItemMetadataRepresentation; + const metadataMap = new MetadataMap(); + for (const key of Object.keys(ItemTypeToValue)) { + metadataMap[ItemTypeToValue[key]] = [Object.assign(new MetadataValue(), { + value: `${valuePrefix}${ItemTypeToValue[key]}` + })]; + } + item.metadata = metadataMap; + + for (const itemType of Object.keys(ItemTypeToValue)) { + describe(`when creating an ItemMetadataRepresentation`, () => { + beforeEach(() => { + item.metadata['relationship.type'] = [ + Object.assign(new MetadataValue(), { + value: itemType + }) + ]; + + itemMetadataRepresentation = Object.assign(new ItemMetadataRepresentation(), item); + }); + + it('should have a representation type of item', () => { + expect(itemMetadataRepresentation.representationType).toEqual(MetadataRepresentationType.Item); + }); + + it('should return the correct value when calling getValue', () => { + expect(itemMetadataRepresentation.getValue()).toEqual(`${valuePrefix}${ItemTypeToValue[itemType]}`); + }); + + it('should return the correct item type', () => { + expect(itemMetadataRepresentation.itemType).toEqual(item.firstMetadataValue('relationship.type')); + }); + }); + } +}); diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ec1445613b07127f1def2b24b2bba83581f6ff1 --- /dev/null +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts @@ -0,0 +1,46 @@ +import { Item } from '../../item.model'; +import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; +import { hasValue } from '../../../../shared/empty.util'; + +/** + * An object to convert item types into the metadata field it should render for the item's value + */ +export const ItemTypeToValue = { + Default: 'dc.title', + Person: 'dc.contributor.author', + OrgUnit: 'dc.title' +}; + +/** + * This class determines which fields to use when rendering an Item as a metadata value. + */ +export class ItemMetadataRepresentation extends Item implements MetadataRepresentation { + + /** + * The type of item this item can be represented as + */ + get itemType(): string { + return this.firstMetadataValue('relationship.type'); + } + + /** + * Fetch the way this item should be rendered as in a list + */ + get representationType(): MetadataRepresentationType { + return MetadataRepresentationType.Item; + } + + /** + * Get the value to display, depending on the itemType + */ + getValue(): string { + let metadata; + if (hasValue(ItemTypeToValue[this.itemType])) { + metadata = ItemTypeToValue[this.itemType]; + } else { + metadata = ItemTypeToValue.Default; + } + return this.firstMetadataValue(metadata); + } + +} diff --git a/src/app/core/shared/metadata-representation/metadata-representation.model.ts b/src/app/core/shared/metadata-representation/metadata-representation.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..58e5bf906ffa213d5ecce6f644790dc005e704f4 --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadata-representation.model.ts @@ -0,0 +1,31 @@ +/** + * An Enum defining the representation type of metadata + */ +export enum MetadataRepresentationType { + None = 'none', + Item = 'item', + AuthorityControlled = 'authority_controlled', + PlainText = 'plain_text' +} + +/** + * An interface containing information about how we should represent certain metadata + */ +export interface MetadataRepresentation { + /** + * The type of item this metadata is representing + * e.g. 'Person' + * This can be used for template matching + */ + itemType: string; + + /** + * How we should render the metadata in a list + */ + representationType: MetadataRepresentationType, + + /** + * Fetches the value to be displayed + */ + getValue(): string +} diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea48d345c5d19af613227b13b1675f0c98b71a02 --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts @@ -0,0 +1,54 @@ +import { MetadatumRepresentation } from './metadatum-representation.model'; +import { MetadataRepresentationType } from '../metadata-representation.model'; +import { MetadataValue } from '../../metadata.models'; + +describe('MetadatumRepresentation', () => { + const itemType = 'Person'; + const normalMetadatum = Object.assign(new MetadataValue(), { + key: 'dc.contributor.author', + value: 'Test Author' + }); + const authorityMetadatum = Object.assign(new MetadataValue(), { + key: 'dc.contributor.author', + value: 'Test Authority Author', + authority: '1234' + }); + + let metadatumRepresentation: MetadatumRepresentation; + + describe('when creating a MetadatumRepresentation based on a standard Metadatum object', () => { + beforeEach(() => { + metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), normalMetadatum); + }); + + it('should have a representation type of plain text', () => { + expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.PlainText); + }); + + it('should return the correct value when calling getPrimaryValue', () => { + expect(metadatumRepresentation.getValue()).toEqual(normalMetadatum.value); + }); + + it('should return the correct item type', () => { + expect(metadatumRepresentation.itemType).toEqual(itemType); + }); + }); + + describe('when creating a MetadatumRepresentation based on an authority controlled Metadatum object', () => { + beforeEach(() => { + metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), authorityMetadatum); + }); + + it('should have a representation type of plain text', () => { + expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.AuthorityControlled); + }); + + it('should return the correct value when calling getValue', () => { + expect(metadatumRepresentation.getValue()).toEqual(authorityMetadatum.value); + }); + + it('should return the correct item type', () => { + expect(metadatumRepresentation.itemType).toEqual(itemType); + }); + }); +}); diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..595147f3e6f1a2d3ff4ce7ceed7c0ea6dd773195 --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts @@ -0,0 +1,38 @@ +import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; +import { hasValue } from '../../../../shared/empty.util'; +import { MetadataValue } from '../../metadata.models'; + +/** + * This class defines the way the metadatum it extends should be represented + */ +export class MetadatumRepresentation extends MetadataValue implements MetadataRepresentation { + + /** + * The type of item this metadatum can be represented as + */ + itemType: string; + + constructor(itemType: string) { + super(); + this.itemType = itemType; + } + + /** + * Fetch the way this metadatum should be rendered as in a list + */ + get representationType(): MetadataRepresentationType { + if (hasValue(this.authority)) { + return MetadataRepresentationType.AuthorityControlled; + } else { + return MetadataRepresentationType.PlainText; + } + } + + /** + * Get the value to display + */ + getValue(): string { + return this.value; + } + +} diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index ab007c15f6efb952a0563682a89693611ce93ec0..9c7e30dcb4a1a780c5e6eb3413072d9c5adccaae 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -1,7 +1,10 @@ import * as uuidv4 from 'uuid/v4'; import { autoserialize, Serialize, Deserialize } from 'cerialize'; +import { hasValue } from '../../shared/empty.util'; /* tslint:disable:max-classes-per-file */ +const VIRTUAL_METADATA_PREFIX = 'virtual::'; + /** A single metadata value and its properties. */ export interface MetadataValueInterface { @@ -34,6 +37,40 @@ export class MetadataValue implements MetadataValueInterface { /** The string value. */ @autoserialize value: string; + + /** + * The place of this MetadataValue within his list of metadata + * This is used to render metadata in a specific custom order + */ + @autoserialize + place: number; + + /** The authority key used for authority-controlled metadata */ + @autoserialize + authority: string; + + /** The authority confidence value */ + @autoserialize + confidence: number; + + /** + * Returns true if this Metadatum's authority key starts with 'virtual::' + */ + get isVirtual(): boolean { + return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX); + } + + /** + * If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'. + * Returns undefined otherwise. + */ + get virtualValue(): string { + if (this.isVirtual) { + return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); + } else { + return undefined; + } + } } /** Constraints for matching metadata values. */ @@ -64,8 +101,17 @@ export class MetadatumViewModel { /** The string value. */ value: string; - /** The order. */ - order: number; + /** + * The place of this MetadataValue within his list of metadata + * This is used to render metadata in a specific custom order + */ + place: number; + + /** The authority key used for authority-controlled metadata */ + authority: string; + + /** The authority confidence value */ + confidence: number; } /** Serializer used for MetadataMaps. diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index 7fbea14b1369956394ce3f2c9d12e4dec0b9eff6..1e1d7f86d51becfd3f1d09e613549af13caa1ae6 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -9,7 +9,7 @@ import { import { Metadata } from './metadata.utils'; const mdValue = (value: string, language?: string): MetadataValue => { - return { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language }; + return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: undefined, confidence: undefined }); }; const dcDescription = mdValue('Some description'); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 938d646a82282fa07635e60b8b71178399406f2e..62a1957e22f0569b7711e59a5cf6d929d230c4a8 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -205,8 +205,8 @@ export class Metadata { .sort() .forEach((key: string) => { const orderedValues = sortBy(groupedList[key], ['order']); - metadataMap[key] = orderedValues.map((value: MetadataValue) => { - const val = Object.assign({}, value); + metadataMap[key] = orderedValues.map((value: MetadatumViewModel) => { + const val = Object.assign(new MetadataValue(), value); delete (val as any).order; delete (val as any).key; return val; diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 2eb47507b27416144598db8123975637d368a708..564b0ff31999fe15b54a5d57a2aca79dbe729407 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -13,9 +13,11 @@ import { getRequestFromRequestUUID, getResourceLinksFromResponse, getResponseFromEntry, - getSucceededRemoteData + getSucceededRemoteData, redirectToPageNotFoundOn404 } from './operators'; import { RemoteData } from '../data/remote-data'; +import { RemoteDataError } from '../data/remote-data-error'; +import { of as observableOf } from 'rxjs'; describe('Core Module - RxJS Operators', () => { let scheduler: TestScheduler; @@ -193,6 +195,34 @@ describe('Core Module - RxJS Operators', () => { }); }); + describe('redirectToPageNotFoundOn404', () => { + let router; + beforeEach(() => { + router = jasmine.createSpyObj('router', ['navigateByUrl']); + }); + + it('should call navigateByUrl to a 404 page, when the remote data contains a 404 error', () => { + const testRD = new RemoteData(false, false, false, new RemoteDataError(404, 'Not Found', 'Object was not found'), undefined); + + observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/404', { skipLocationChange: true }); + }); + + it('should not call navigateByUrl to a 404 page, when the remote data contains another error than a 404', () => { + const testRD = new RemoteData(false, false, false, new RemoteDataError(500, 'Server Error', 'Something went wrong'), undefined); + + observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('should not call navigateByUrl to a 404 page, when the remote data contains no error', () => { + const testRD = new RemoteData(false, false, true, null, undefined); + + observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + describe('getResponseFromEntry', () => { it('should return the response for all not empty request entries, when they have a value', () => { const source = hot('abcdefg', testRCEs); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index ce9740a0fcc19f581a394fce2ce5c0c07ca9e0e4..ae46691e390574f17b5a12906de1db2bcb61678b 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,5 +1,5 @@ import { Observable } from 'rxjs'; -import { filter, find, flatMap, map, tap } from 'rxjs/operators'; +import { filter, find, flatMap, map, take, tap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; import { RemoteData } from '../data/remote-data'; @@ -10,6 +10,8 @@ import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { PaginatedList } from '../data/paginated-list'; import { SearchResult } from '../../+search-page/search-result.model'; +import { Item } from './item.model'; +import { Router } from '@angular/router'; /** * This file contains custom RxJS operators that can be used in multiple places @@ -62,6 +64,20 @@ export const getSucceededRemoteData = () => <T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> => source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded)); +/** + * Operator that checks if a remote data object contains a page not found error + * When it does contain such an error, it will redirect the user to a page not found, without altering the current URL + * @param router The router used to navigate to a new page + */ +export const redirectToPageNotFoundOn404 = (router: Router) => + <T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> => + source.pipe( + tap((rd: RemoteData<T>) => { + if (rd.hasFailed && rd.error.statusCode === 404) { + router.navigateByUrl('/404', { skipLocationChange: true }); + } + })); + export const getFinishedRemoteData = () => <T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> => source.pipe(find((rd: RemoteData<T>) => !rd.isLoading)); @@ -75,7 +91,7 @@ export const toDSpaceObjectListRD = () => source.pipe( filter((rd: RemoteData<PaginatedList<SearchResult<T>>>) => rd.hasSucceeded), map((rd: RemoteData<PaginatedList<SearchResult<T>>>) => { - const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult<T>) => searchResult.dspaceObject); + const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult<T>) => searchResult.indexableObject); const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList<T>; return Object.assign(rd, { payload: payload }); }) diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index 47c59b95dcc45698f20dce2a80c8a05be6b4427d..5839e140845c13580dd4342f713fbb6c75eb1830 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -11,6 +11,9 @@ export enum ResourceType { ResourcePolicy = 'resourcePolicy', MetadataSchema = 'metadataschema', MetadataField = 'metadatafield', + Relationship = 'relationship', + RelationshipType = 'relationshiptype', + ItemType = 'entitytype', License = 'license', WorkflowItem = 'workflowitem', WorkspaceItem = 'workspaceitem', @@ -23,4 +26,6 @@ export enum ResourceType { AuthStatus = 'status', Authority = 'authority', BrowseEntry = 'browseEntry', + ClaimedTask = 'claimedtask', + PoolTask = 'pooltask' } diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts index b026d6843132dc1c72ebaa301d5ad87b0d1fbb5f..9c8d08609718d375c16f15d3a49fa8df849f0f67 100644 --- a/src/app/core/shared/view-mode.model.ts +++ b/src/app/core/shared/view-mode.model.ts @@ -4,5 +4,6 @@ export enum ViewMode { List = 'list', - Grid = 'grid' + Grid = 'grid', + Detail = 'detail' } diff --git a/src/app/core/submission/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts index 809178176080953d5afc92ee42db9c6d2fe584e8..f674ebdf72dd96dfe15275c415b179461b30c69d 100644 --- a/src/app/core/submission/models/normalized-submission-object.model.ts +++ b/src/app/core/submission/models/normalized-submission-object.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; @@ -17,6 +17,12 @@ export class NormalizedSubmissionObject<T extends DSpaceObject> extends Normaliz @autoserialize id: string; + /** + * The workspaceitem/workflowitem identifier + */ + @autoserializeAs(String, 'id') + uuid: string; + /** * The workspaceitem/workflowitem last modified date */ diff --git a/src/app/core/submission/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts index 6b2d9a03b955d85d13ff1db82681cb5bd142bb62..23f75553c5f4a3883ba8f554334a5c787b6f21c3 100644 --- a/src/app/core/submission/models/submission-object.model.ts +++ b/src/app/core/submission/models/submission-object.model.ts @@ -25,6 +25,11 @@ export abstract class SubmissionObject extends DSpaceObject implements Cacheable */ id: string; + /** + * The workspaceitem/workflowitem identifier + */ + uuid: string; + /** * The workspaceitem/workflowitem last modified date */ diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index deb8a814ce0f1c6ddab52029a9f9057a3795d624..a0811c8f2d247e689ec076f7536cae44567fd1ae 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -25,16 +25,16 @@ export function isServerFormValue(obj: any): boolean { && obj.hasOwnProperty('value') && obj.hasOwnProperty('language') && obj.hasOwnProperty('authority') - && obj.hasOwnProperty('confidence') - && obj.hasOwnProperty('place')) + && obj.hasOwnProperty('confidence')) } /** * Export a function to normalize sections object of the server response * * @param obj + * @param objIndex */ -export function normalizeSectionData(obj: any) { +export function normalizeSectionData(obj: any, objIndex?: number) { let result: any = obj; if (isNotNull(obj)) { // If is an Instance of FormFieldMetadataValueObject normalize it @@ -47,14 +47,14 @@ export function normalizeSectionData(obj: any) { obj.language, obj.authority, (obj.display || obj.value), - obj.place, + obj.place || objIndex, obj.confidence, obj.otherInformation ); } else if (Array.isArray(obj)) { result = []; obj.forEach((item, index) => { - result[index] = normalizeSectionData(item); + result[index] = normalizeSectionData(item, index); }); } else if (typeof obj === 'object') { result = Object.create({}); @@ -90,11 +90,10 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) - && (data.statusCode === 201 || data.statusCode === 200)) { + && this.isSuccessStatus(data.statusCode)) { const dataDefinition = this.processResponse<SubmissionObject | ConfigObject>(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 + } else if (isEmpty(data.payload) && this.isSuccessStatus(data.statusCode)) { return new SubmissionSuccessResponse(null, data.statusCode, data.statusText); } else { return new ErrorResponse( @@ -138,9 +137,9 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService // If entry is not an array, for sure is not a section of type form if (Array.isArray(entry)) { normalizedSectionData[metdadataId] = []; - entry.forEach((valueItem) => { + entry.forEach((valueItem, index) => { // Parse value and normalize it - const normValue = normalizeSectionData(valueItem); + const normValue = normalizeSectionData(valueItem, index); if (isNotEmpty(normValue)) { normalizedSectionData[metdadataId].push(normValue); } diff --git a/src/app/core/tasks/claimed-task-data.service.spec.ts b/src/app/core/tasks/claimed-task-data.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7be0830ec17a890c7edb0d229a5043c5751126e --- /dev/null +++ b/src/app/core/tasks/claimed-task-data.service.spec.ts @@ -0,0 +1,108 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { CoreState } from '../core.reducers'; +import { ClaimedTaskDataService } from './claimed-task-data.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +describe('ClaimedTaskDataService', () => { + let service: ClaimedTaskDataService; + let options: HttpOptions; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'claimedtasks'; + const requestService: any = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store<CoreState>; + + function initTestService(): ClaimedTaskDataService { + return new ClaimedTaskDataService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + service = initTestService(); + options = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + }); + + describe('approveTask', () => { + + it('should call postToEndpoint method', () => { + const scopeId = '1234'; + const body = { + submit_approve: 'true' + }; + + spyOn(service, 'postToEndpoint'); + requestService.uriEncodeBody.and.returnValue(body); + + service.approveTask(scopeId); + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options); + }); + }); + + describe('rejectTask', () => { + + it('should call postToEndpoint method', () => { + const scopeId = '1234'; + const reason = 'test reject'; + const body = { + submit_reject: 'true', + reason + }; + + spyOn(service, 'postToEndpoint'); + requestService.uriEncodeBody.and.returnValue(body); + + service.rejectTask(reason, scopeId); + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options); + }); + }); + + describe('returnToPoolTask', () => { + + it('should call deleteById method', () => { + const scopeId = '1234'; + + spyOn(service, 'deleteById'); + + service.returnToPoolTask(scopeId); + + expect(service.deleteById).toHaveBeenCalledWith(linkPath, scopeId, options); + }); + }); +}); diff --git a/src/app/core/tasks/claimed-task-data.service.ts b/src/app/core/tasks/claimed-task-data.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..dda42e1c6726476ed18305ee8b0a66e7b45b90ef --- /dev/null +++ b/src/app/core/tasks/claimed-task-data.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { RequestService } from '../data/request.service'; +import { ClaimedTask } from './models/claimed-task-object.model'; +import { TasksService } from './tasks.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ProcessTaskResponse } from './models/process-task-response'; + +/** + * The service handling all REST requests for ClaimedTask + */ +@Injectable() +export class ClaimedTaskDataService extends TasksService<ClaimedTask> { + + /** + * The endpoint link name + */ + protected linkPath = 'claimedtasks'; + + /** + * When true, a new request is always dispatched + */ + protected forceBypassCache = true; + + /** + * Initialize instance variables + * + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {NormalizedObjectBuildService} dataBuildService + * @param {Store<CoreState>} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DSOChangeAnalyzer<ClaimedTask} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store<CoreState>, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer<ClaimedTask>) { + super(); + } + + /** + * Make a request to approve the given task + * + * @param scopeId + * The task id + * @return {Observable<ProcessTaskResponse>} + * Emit the server response + */ + public approveTask(scopeId: string): Observable<ProcessTaskResponse> { + const body = { + submit_approve: 'true' + }; + return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions()); + } + + /** + * Make a request to reject the given task + * + * @param reason + * The reason of reject + * @param scopeId + * The task id + * @return {Observable<ProcessTaskResponse>} + * Emit the server response + */ + public rejectTask(reason: string, scopeId: string): Observable<ProcessTaskResponse> { + const body = { + submit_reject: 'true', + reason + }; + return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions()); + } + + /** + * Make a request to return the given task to the pool + * + * @param scopeId + * The task id + * @return {Observable<ProcessTaskResponse>} + * Emit the server response + */ + public returnToPoolTask(scopeId: string): Observable<ProcessTaskResponse> { + return this.deleteById(this.linkPath, scopeId, this.makeHttpOptions()); + } + +} diff --git a/src/app/core/tasks/models/claimed-task-object.model.ts b/src/app/core/tasks/models/claimed-task-object.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..212e75ed95126495cb84cd450b957a00553011ec --- /dev/null +++ b/src/app/core/tasks/models/claimed-task-object.model.ts @@ -0,0 +1,8 @@ +import { TaskObject } from './task-object.model'; + +/** + * A model class for a ClaimedTask. + */ +export class ClaimedTask extends TaskObject { + +} diff --git a/src/app/core/tasks/models/normalized-claimed-task-object.model.ts b/src/app/core/tasks/models/normalized-claimed-task-object.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2c3f12bc48554e766e899721fed08d16e7af034 --- /dev/null +++ b/src/app/core/tasks/models/normalized-claimed-task-object.model.ts @@ -0,0 +1,39 @@ +import { NormalizedTaskObject } from './normalized-task-object.model'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { autoserialize, inheritSerialization } from 'cerialize'; +import { ClaimedTask } from './claimed-task-object.model'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * A normalized model class for a ClaimedTask. + */ +@mapsTo(ClaimedTask) +@inheritSerialization(NormalizedTaskObject) +export class NormalizedClaimedTask extends NormalizedTaskObject<ClaimedTask> { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; + +} diff --git a/src/app/core/tasks/models/normalized-pool-task-object.model.ts b/src/app/core/tasks/models/normalized-pool-task-object.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..22cda6ff9cb6e77b0e60faea71c5919168864bb8 --- /dev/null +++ b/src/app/core/tasks/models/normalized-pool-task-object.model.ts @@ -0,0 +1,38 @@ +import { NormalizedTaskObject } from './normalized-task-object.model'; +import { PoolTask } from './pool-task-object.model'; +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * A normalized model class for a PoolTask. + */ +@mapsTo(PoolTask) +@inheritSerialization(NormalizedTaskObject) +export class NormalizedPoolTask extends NormalizedTaskObject<PoolTask> { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; +} diff --git a/src/app/core/tasks/models/normalized-task-object.model.ts b/src/app/core/tasks/models/normalized-task-object.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..52c274e3a8b14d5ccb46975456319dec5633e21a --- /dev/null +++ b/src/app/core/tasks/models/normalized-task-object.model.ts @@ -0,0 +1,39 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { TaskObject } from './task-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * An abstract normalized model class for a TaskObject. + */ +@mapsTo(TaskObject) +@inheritSerialization(NormalizedDSpaceObject) +export abstract class NormalizedTaskObject<T extends DSpaceObject> extends NormalizedDSpaceObject<T> { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; +} diff --git a/src/app/core/tasks/models/pool-task-object.model.ts b/src/app/core/tasks/models/pool-task-object.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d98d3e1a5776e6ffce820d029d16e29476854d2 --- /dev/null +++ b/src/app/core/tasks/models/pool-task-object.model.ts @@ -0,0 +1,8 @@ +import { TaskObject } from './task-object.model'; + +/** + * A model class for a PoolTask. + */ +export class PoolTask extends TaskObject { + +} diff --git a/src/app/core/tasks/models/process-task-response.ts b/src/app/core/tasks/models/process-task-response.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca4bc9a0682dc6e20b7ec316cce154c300749885 --- /dev/null +++ b/src/app/core/tasks/models/process-task-response.ts @@ -0,0 +1,17 @@ +import { RemoteDataError } from '../../data/remote-data-error'; + +/** + * A class to represent the data retrieved by after processing a task + */ +export class ProcessTaskResponse { + constructor( + private isSuccessful: boolean, + public error?: RemoteDataError, + public payload?: any + ) { + } + + get hasSucceeded(): boolean { + return this.isSuccessful; + } +} diff --git a/src/app/core/tasks/models/task-object.model.ts b/src/app/core/tasks/models/task-object.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..97a1c9f59e57ce456cc1fb89198222bca92b0ff8 --- /dev/null +++ b/src/app/core/tasks/models/task-object.model.ts @@ -0,0 +1,33 @@ +import { Observable } from 'rxjs'; + +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { RemoteData } from '../../data/remote-data'; +import { Workflowitem } from '../../submission/models/workflowitem.model'; + +/** + * An abstract model class for a TaskObject. + */ +export class TaskObject extends DSpaceObject implements CacheableObject, ListableObject { + + /** + * The task identifier + */ + id: string; + + /** + * The workflow step + */ + step: string; + + /** + * The task action type + */ + action: string; + + /** + * The workflowitem object whom this task is related + */ + workflowitem: Observable<RemoteData<Workflowitem>> | Workflowitem; +} diff --git a/src/app/core/tasks/pool-task-data.service.spec.ts b/src/app/core/tasks/pool-task-data.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f40c6e89c17be4d3b596db4cda75962ca965743 --- /dev/null +++ b/src/app/core/tasks/pool-task-data.service.spec.ts @@ -0,0 +1,70 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { CoreState } from '../core.reducers'; +import { PoolTaskDataService } from './pool-task-data.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +describe('PoolTaskDataService', () => { + let service: PoolTaskDataService; + let options: HttpOptions; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'pooltasks'; + const requestService = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store<CoreState>; + + function initTestService(): PoolTaskDataService { + return new PoolTaskDataService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + service = initTestService(); + options = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + }); + + describe('claimTask', () => { + + it('should call postToEndpoint method', () => { + spyOn(service, 'postToEndpoint'); + const scopeId = '1234'; + service.claimTask(scopeId); + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, {}, scopeId, options); + }); + }); +}); diff --git a/src/app/core/tasks/pool-task-data.service.ts b/src/app/core/tasks/pool-task-data.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a93450d4d6cc4aaff19d5611a20984335e8da8d --- /dev/null +++ b/src/app/core/tasks/pool-task-data.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Observable } from 'rxjs'; +import { Store } from '@ngrx/store'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { RequestService } from '../data/request.service'; +import { PoolTask } from './models/pool-task-object.model'; +import { TasksService } from './tasks.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ProcessTaskResponse } from './models/process-task-response'; + +/** + * The service handling all REST requests for PoolTask + */ +@Injectable() +export class PoolTaskDataService extends TasksService<PoolTask> { + + /** + * The endpoint link name + */ + protected linkPath = 'pooltasks'; + + /** + * When true, a new request is always dispatched + */ + protected forceBypassCache = true; + + /** + * Initialize instance variables + * + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {NormalizedObjectBuildService} dataBuildService + * @param {Store<CoreState>} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DSOChangeAnalyzer<ClaimedTask} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store<CoreState>, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer<PoolTask>) { + super(); + } + + /** + * Make a request to claim the given task + * + * @param scopeId + * The task id + * @return {Observable<ProcessTaskResponse>} + * Emit the server response + */ + public claimTask(scopeId: string): Observable<ProcessTaskResponse> { + return this.postToEndpoint(this.linkPath, {}, scopeId, this.makeHttpOptions()); + } +} diff --git a/src/app/core/tasks/task-response-parsing.service.ts b/src/app/core/tasks/task-response-parsing.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..7445f9d26729556cfa9bd47f6fd263124918239a --- /dev/null +++ b/src/app/core/tasks/task-response-parsing.service.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@angular/core'; + +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; + +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { ErrorResponse, RestResponse, TaskResponse } from '../cache/response.models'; + +/** + * Provides methods to parse response for a task request. + */ +@Injectable() +export class TaskResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = false; + + /** + * Initialize instance variables + * + * @param {GlobalConfig} EnvConfig + * @param {ObjectCacheService} objectCache + */ + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService,) { + super(); + } + + /** + * Parses data from the tasks endpoints + * + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (this.isSuccessStatus(data.statusCode)) { + return new TaskResponse( data.statusCode, data.statusText); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from server'), + { statusCode: data.statusCode, statusText: data.statusText } + ) + ); + } + } + +} diff --git a/src/app/core/tasks/tasks.service.spec.ts b/src/app/core/tasks/tasks.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..753ce2ddd5c53f1e730f29eeceb6b9d615fc4cdb --- /dev/null +++ b/src/app/core/tasks/tasks.service.spec.ts @@ -0,0 +1,130 @@ +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { TasksService } from './tasks.service'; +import { RequestService } from '../data/request.service'; +import { TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { TaskObject } from './models/task-object.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { compare, Operation } from 'fast-json-patch'; +import { NormalizedTaskObject } from './models/normalized-task-object.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +const LINK_NAME = 'test'; + +/* tslint:disable:max-classes-per-file */ +class TestTask extends TaskObject { +} + +class TestService extends TasksService<TestTask> { + protected linkPath = LINK_NAME; + protected forceBypassCache = true; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store<CoreState>, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer<TestTask>) { + super(); + } +} + +class NormalizedTestTaskObject extends NormalizedTaskObject<TestTask> { +} + +class DummyChangeAnalyzer implements ChangeAnalyzer<NormalizedTestTaskObject> { + diff(object1: NormalizedTestTaskObject, object2: NormalizedTestTaskObject): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } + +} +/* tslint:enable:max-classes-per-file */ + +describe('TasksService', () => { + let scheduler: TestScheduler; + let service: TestService; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'testTask'; + const requestService = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = new DummyChangeAnalyzer() as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store<CoreState>; + + function initTestService(): TestService { + return new TestService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + + }); + + describe('postToEndpoint', () => { + + it('should configure a new TaskPostRequest', () => { + const expected = new TaskPostRequest(requestService.generateRequestId(), `${taskEndpoint}/${linkPath}`, {}); + scheduler.schedule(() => service.postToEndpoint('testTask', {}).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('deleteById', () => { + + it('should configure a new TaskDeleteRequest', () => { + const scopeId = '1234'; + const expected = new TaskDeleteRequest(requestService.generateRequestId(), `${taskEndpoint}/${linkPath}/${scopeId}`, null); + scheduler.schedule(() => service.deleteById('testTask', scopeId).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + +}); diff --git a/src/app/core/tasks/tasks.service.ts b/src/app/core/tasks/tasks.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..f39b144c6aab45a9c550d2c30a4d72a661021989 --- /dev/null +++ b/src/app/core/tasks/tasks.service.ts @@ -0,0 +1,125 @@ +import { HttpHeaders } from '@angular/common/http'; + +import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map, mergeMap, tap } from 'rxjs/operators'; + +import { DataService } from '../data/data.service'; +import { DeleteRequest, FindAllOptions, PostRequest, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { ProcessTaskResponse } from './models/process-task-response'; +import { RemoteDataError } from '../data/remote-data-error'; +import { getResponseFromEntry } from '../shared/operators'; +import { ErrorResponse, MessageResponse, RestResponse } from '../cache/response.models'; +import { CacheableObject } from '../cache/object-cache.reducer'; + +/** + * An abstract class that provides methods to handle task requests. + */ +export abstract class TasksService<T extends CacheableObject> extends DataService<T> { + + public getBrowseEndpoint(options: FindAllOptions): Observable<string> { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Fetch a RestRequest + * + * @param requestId + * The base endpoint for the type of object + * @return Observable<ProcessTaskResponse> + * server response + */ + protected fetchRequest(requestId: string): Observable<ProcessTaskResponse> { + const responses = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + const errorResponses = responses.pipe( + filter((response: RestResponse) => !response.isSuccessful), + mergeMap((response: ErrorResponse) => observableOf( + new ProcessTaskResponse( + response.isSuccessful, + new RemoteDataError(response.statusCode, response.statusText, response.errorMessage) + )) + )); + const successResponses = responses.pipe( + filter((response: RestResponse) => response.isSuccessful), + map((response: MessageResponse) => new ProcessTaskResponse(response.isSuccessful)), + distinctUntilChanged() + ); + return observableMerge(errorResponses, successResponses); + } + + /** + * Create the HREF for a specific submission object based on its identifier + * + * @param endpoint + * The base endpoint for the type of object + * @param resourceID + * The identifier for the object + */ + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + /** + * Make a new post request + * + * @param linkPath + * The endpoint link name + * @param body + * The request body + * @param scopeId + * The task id to be removed + * @param options + * The HttpOptions object + * @return Observable<SubmitDataResponseDefinitionObject> + * server response + */ + public postToEndpoint(linkPath: string, body: any, scopeId?: string, options?: HttpOptions): Observable<ProcessTaskResponse> { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + distinctUntilChanged(), + map((endpointURL: string) => new TaskPostRequest(requestId, endpointURL, body, options)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap((request: PostRequest) => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Delete an existing task on the server + * + * @param linkPath + * The endpoint link name + * @param scopeId + * The task id to be removed + * @param options + * The HttpOptions object + * @return Observable<SubmitDataResponseDefinitionObject> + * server response + */ + public deleteById(linkPath: string, scopeId: string, options?: HttpOptions): Observable<ProcessTaskResponse> { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkPath || this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + map((endpointURL: string) => new TaskDeleteRequest(requestId, endpointURL, null, options)), + tap((request: DeleteRequest) => this.requestService.configure(request)), + flatMap((request: DeleteRequest) => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Create a new HttpOptions + */ + protected makeHttpOptions() { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + return options; + } +} diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index c7f41a07a3c4940447c12a9fa250b77083970568..fec75b2fd35a52a502405995c27eca8eff391fba 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -1,5 +1,6 @@ <footer class="footer"> <div class="container-fluid content-container-fluid"> + <img src="assets/images/dspace-logo.png" /> <p> <a href="http://www.dspace.org/">{{ 'footer.link.dspace' | translate}}</a> {{ 'footer.copyright' | translate:{year : dateObj | date:'y'} }} diff --git a/src/app/footer/footer.component.scss b/src/app/footer/footer.component.scss index fec6473f687d68ebdd6e80a5cf805d4d43ba9b3a..bd141706da1a6e8ce9128818009d375f908182a0 100644 --- a/src/app/footer/footer.component.scss +++ b/src/app/footer/footer.component.scss @@ -2,6 +2,7 @@ $footer-bg: $gray-100; $footer-border: 1px solid darken($footer-bg, 10%); $footer-padding: $spacer * 1.5; +$footer-logo-height: 55px; .footer { background-color: $footer-bg; @@ -12,4 +13,7 @@ $footer-padding: $spacer * 1.5; p { margin: 0; } + img { + height: $footer-logo-height; + } } diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 402eb7a44dcfacee6280840b9c9441e3556c3fe3..a03fd01c533919d34b63804290e639060fd2b0bb 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -5,7 +5,7 @@ </a> <nav class="navbar navbar-light navbar-expand-md float-right px-0"> - <a href="#" class="px-1"><i class="fas fa-search fa-lg fa-fw" [title]="'nav.search' | translate"></i></a> + <a routerLink="/search" class="px-1"><i class="fas fa-search fa-lg fa-fw" [title]="'nav.search' | translate"></i></a> <ds-lang-switch></ds-lang-switch> <ds-auth-nav-menu></ds-auth-nav-menu> <div class="pl-2"> diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 008a86599d8042e8d6394e31942923240f9212da..48b316af4bc04135a8fa7abdd38792296df0055a 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -115,7 +115,7 @@ export class NavbarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.statistics', - link: '#' + link: '' } as LinkMenuItemModel, index: 2 }, 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 97c3e393533c83f22e7af61711e6e6791ca6aff9..b560283ad50efcdfc49d90a925ae588119bd5d5d 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 @@ -13,10 +13,9 @@ <li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"> <div ngbDropdown placement="bottom-right" class="d-inline-block" @fadeInOut> <a href="#" id="dropdownUser" (click)="$event.preventDefault()" class="px-1" ngbDropdownToggle><i class="fas fa-user-circle fa-lg fa-fw" [title]="'nav.logout' | translate"></i></a> - <ul id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser"> - <li class="dropdown-item">{{(user | async).name}} ({{(user | async).email}})</li> - <li class="dropdown-item"><ds-log-out></ds-log-out></li> - </ul> + <div id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser"> + <ds-user-menu></ds-user-menu> + </div> </div> </li> <li *ngIf="(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item"> 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 ff4948caa00fbc485ca1fd3103df845962e3fcac..5e01494674556eb6155e80a1fe67a4a365be3ca4 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 @@ -228,8 +228,8 @@ describe('AuthNavMenuComponent', () => { fixture.destroy(); component = null; }); - it('should render logout dropdown menu', () => { - const logoutDropdownMenu = deNavMenuItem.query(By.css('ul[id=logoutDropdownMenu]')); + it('should render UserMenuComponent component', () => { + const logoutDropdownMenu = deNavMenuItem.query(By.css('ds-user-menu')); expect(logoutDropdownMenu.nativeElement).toBeDefined(); }); }) diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html new file mode 100644 index 0000000000000000000000000000000000000000..fef47b395b94652910bbfbaccffe2d873eb32a14 --- /dev/null +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html @@ -0,0 +1,9 @@ +<ds-loading *ngIf="(loading$ | async)"></ds-loading> +<div *ngIf="!(loading$ | async)"> + <span class="dropdown-item-text">{{(user$ | async)?.name}} ({{(user$ | async)?.email}})</span> + <a class="dropdown-item" [routerLink]="[mydspaceRoute]" routerLinkActive="active">{{'nav.mydspace' | translate}}</a> + <div class="dropdown-divider"></div> + <ds-log-out></ds-log-out> +</div> + + diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.scss b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..512d9e0917295534b5e0ac0d398aca877b90ab0b --- /dev/null +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts @@ -0,0 +1,151 @@ +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; + +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { UserMenuComponent } from './user-menu.component'; +import { authReducer, AuthState } from '../../../core/auth/auth.reducer'; +import { AuthTokenInfo } from '../../../core/auth/models/auth-token-info.model'; +import { EPersonMock } from '../../testing/eperson-mock'; +import { AppState } from '../../../app.reducer'; +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; +import { cold } from 'jasmine-marbles'; +import { By } from '@angular/platform-browser'; + +describe('UserMenuComponent', () => { + + let component: UserMenuComponent; + let fixture: ComponentFixture<UserMenuComponent>; + let deUserMenu: DebugElement; + let authState: AuthState; + let authStateLoading: AuthState; + + function init() { + authState = { + authenticated: true, + loaded: true, + loading: false, + authToken: new AuthTokenInfo('test_token'), + user: EPersonMock + }; + authStateLoading = { + authenticated: true, + loaded: true, + loading: true, + authToken: null, + user: EPersonMock + }; + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(authReducer), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ + UserMenuComponent + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + + })); + + beforeEach(() => { + init(); + }); + + describe('when auth state is loading', () => { + beforeEach(inject([Store], (store: Store<AppState>) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authStateLoading; + }); + + // create component and test fixture + fixture = TestBed.createComponent(UserMenuComponent); + + // get test component from the fixture + component = fixture.componentInstance; + + fixture.detectChanges(); + + deUserMenu = fixture.debugElement.query(By.css('div')); + })); + + afterEach(() => { + fixture.destroy(); + }); + + it('should init component properly', () => { + expect(component).toBeDefined(); + + expect(component.loading$).toBeObservable(cold('b', { + b: true + })); + + expect(component.user$).toBeObservable(cold('c', { + c: EPersonMock + })); + + expect(deUserMenu).toBeNull(); + }); + + }); + + describe('when auth state is not loading', () => { + beforeEach(inject([Store], (store: Store<AppState>) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(UserMenuComponent); + + // get test component from the fixture + component = fixture.componentInstance; + + fixture.detectChanges(); + + deUserMenu = fixture.debugElement.query(By.css('div')); + })); + + afterEach(() => { + fixture.destroy(); + }); + + it('should init component properly', () => { + expect(component).toBeDefined(); + + expect(component.loading$).toBeObservable(cold('b', { + b: false + })); + + expect(component.user$).toBeObservable(cold('c', { + c: EPersonMock + })); + + expect(deUserMenu).toBeDefined(); + }); + + it('should display user name and email', () => { + const user = 'User Test (test@test.com)'; + const span = deUserMenu.query(By.css('.dropdown-item-text')); + expect(span).toBeDefined(); + expect(span.nativeElement.innerHTML).toBe(user); + }) + + }); + +}); diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3c21b4e2487ad290a080e785bd17ddab0e8c7b4 --- /dev/null +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts @@ -0,0 +1,54 @@ +import { Component, OnInit } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { select, Store } from '@ngrx/store'; + +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { AppState } from '../../../app.reducer'; +import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors'; +import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component'; + +/** + * This component represents the user nav menu. + */ +@Component({ + selector: 'ds-user-menu', + templateUrl: './user-menu.component.html', + styleUrls: ['./user-menu.component.scss'] +}) +export class UserMenuComponent implements OnInit { + + /** + * True if the authentication is loading. + * @type {Observable<boolean>} + */ + public loading$: Observable<boolean>; + + /** + * The authenticated user. + * @type {Observable<EPerson>} + */ + public user$: Observable<EPerson>; + + /** + * The mydspace page route. + * @type {string} + */ + public mydspaceRoute = MYDSPACE_ROUTE; + + constructor(private store: Store<AppState>) { + } + + /** + * Initialize all instance variables + */ + ngOnInit(): void { + + // set loading + this.loading$ = this.store.pipe(select(isAuthenticationLoading)); + + // set user + this.user$ = this.store.pipe(select(getAuthenticatedUser)); + + } +} diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html index 1e0deed4b908d54f5fd75e1d661c59bf64796731..662144823d104a3c3ce2912f550bb18685397d6c 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html @@ -13,8 +13,8 @@ </button> <button *ngFor="let listEntry of (listEntries$ | async)?.payload.page" class="list-group-item list-group-item-action border-0 list-entry" - title="{{ listEntry.dspaceObject.name }}" - (click)="onSelect.emit(listEntry.dspaceObject)" #listEntryElement> + title="{{ listEntry.indexableObject.name }}" + (click)="onSelect.emit(listEntry.indexableObject)" #listEntryElement> <ds-wrapper-list-element [object]="listEntry"></ds-wrapper-list-element> </button> -</div> \ No newline at end of file +</div> diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index 04111a4ea6e9aec980efa873430e5c6e0b8a739c..5ec553222b1c47bf01664828991ea67f3b24c75b 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -1,4 +1,4 @@ -import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { DSOSelectorComponent } from './dso-selector.component'; @@ -27,7 +27,7 @@ describe('DSOSelectorComponent', () => { language: undefined })] }; - searchResult.dspaceObject = item; + searchResult.indexableObject = item; searchResult.hitHighlights = {}; const searchService = jasmine.createSpyObj('searchService', { search: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(undefined, [searchResult]))) diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts index 1e129c0dbedd363ef2fa9d5a2eff047d1470521c..0533addb0128242200c3890d0d109785b36e58f3 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -1,7 +1,5 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; -import { Community } from '../../../../core/shared/community.model'; -import { RemoteData } from '../../../../core/data/remote-data'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -9,10 +7,7 @@ import { COLLECTION_PARENT_PARAMETER, getCollectionCreatePath } from '../../../../+collection-page/collection-page-routing.module'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from '../dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; /** * Component to wrap a list of existing communities inside a modal diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index dac5888bf7ff83de029dfc21a1980d9fb573ddc4..29af9f624e0a306976fc4d4347182e5889b3ca7e 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -1,18 +1,9 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Community } from '../../../../core/shared/community.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { Collection } from '../../../../core/shared/collection.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; -import { hasValue } from '../../../empty.util'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from '../dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; /** * Component to wrap a list of existing collections inside a modal diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index ea857f7d62adf91d19599f7c67d199fc38f93a66..4ceaeccb3a3053558a77077708dc9bfbee3d82e0 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -5,10 +5,7 @@ import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model' import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; import { of as observableOf } from 'rxjs'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from './dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from './dso-selector-modal-wrapper.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ActivatedRoute } from '@angular/router'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; @@ -16,7 +13,7 @@ import { first } from 'rxjs/operators'; import { By } from '@angular/platform-browser'; import { DSOSelectorComponent } from '../dso-selector/dso-selector.component'; import { MockComponent } from 'ng-mocks'; -import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models'; +import { MetadataValue } from '../../../core/shared/metadata.models'; describe('DSOSelectorModalWrapperComponent', () => { let component: DSOSelectorModalWrapperComponent; diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index 351a92302c308e9ca3125c4b11721703194c3fcd..881476cac6e088be2728952064db318c8d1d6ac5 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -1,4 +1,4 @@ -import { Component, Injectable, Input, OnInit } from '@angular/core'; +import { Injectable, Input, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts index 9182df80452ffe118d37ccb0c22c8825a8d1e21d..dae36d3017d705937ea20eb19d1e4f1a271b44c8 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -1,19 +1,10 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; -import { Community } from '../../../../core/shared/community.model'; -import { RemoteData } from '../../../../core/data/remote-data'; +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { Collection } from '../../../../core/shared/collection.model'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Item } from '../../../../core/shared/item.model'; import { getItemEditPath } from '../../../../+item-page/item-page-routing.module'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from '../dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; /** * Component to wrap a list of existing items inside a modal diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index d61134347ac7ef83971a5e2098c3c8c705607a2e..a44a20d4bd6fad264b6db0a0266d474e41fe2415 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -168,7 +168,7 @@ export class DsDynamicTagComponent extends DynamicFormControlComponent implement } private addTagsToChips() { - if (!this.hasAuthority || !this.model.authorityOptions.closed) { + if (hasValue(this.currentValue) && (!this.hasAuthority || !this.model.authorityOptions.closed)) { let res: string[] = []; res = this.currentValue.split(','); diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts index 4107586e7bff5eaf7ec693d6c76d481ad3328c77..669c416eb901a3017342265a2ed73a038aa9f449 100644 --- a/src/app/shared/form/form.component.spec.ts +++ b/src/app/shared/form/form.component.spec.ts @@ -23,7 +23,7 @@ import { MockStore } from '../testing/mock-store'; import { FormFieldMetadataValueObject } from './builder/models/form-field-metadata-value.model'; import { GLOBAL_CONFIG } from '../../../config'; import { createTestComponent } from '../testing/utils'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { BehaviorSubject } from 'rxjs'; let TEST_FORM_MODEL; diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 727421c83ec0e5fe3f1c962c9a9b2e430db7672b..9f59f42cc411731f5fd3d6597700d765feb40d31 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -12,7 +12,7 @@ import { ViewChildren } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { hasValue, isNotEmpty, isNotUndefined } from '../empty.util'; +import { hasValue, isNotEmpty } from '../empty.util'; import { InputSuggestion } from './input-suggestions.model'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; diff --git a/src/app/shared/items/item-type-decorator.ts b/src/app/shared/items/item-type-decorator.ts new file mode 100644 index 0000000000000000000000000000000000000000..2420e719087a7b876bb0745a7de3f2143f9e548c --- /dev/null +++ b/src/app/shared/items/item-type-decorator.ts @@ -0,0 +1,65 @@ +import { hasNoValue, hasValue } from '../empty.util'; +import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model'; + +export enum ItemViewMode { + Element = 'element', + Full = 'full', + Metadata = 'metadata' +} + +export const DEFAULT_ITEM_TYPE = 'Default'; +export const DEFAULT_VIEW_MODE = ItemViewMode.Element; +export const NO_REPRESENTATION_TYPE = MetadataRepresentationType.None; +export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText; + +const map = new Map(); + +/** + * Decorator used for rendering simple item pages by type and viewMode (and optionally a representationType) + * @param type + * @param viewMode + * @param representationType + */ +export function rendersItemType(type: string, viewMode: string, representationType?: MetadataRepresentationType) { + return function decorator(component: any) { + if (hasNoValue(map.get(viewMode))) { + map.set(viewMode, new Map()); + } + if (hasNoValue(map.get(viewMode).get(type))) { + map.get(viewMode).set(type, new Map()); + } + if (hasNoValue(representationType)) { + representationType = NO_REPRESENTATION_TYPE; + } + if (hasValue(map.get(viewMode).get(type).get(representationType))) { + throw new Error(`There can't be more than one component to render Metadata of type "${type}" in view mode "${viewMode}" with representation type "${representationType}"`); + } + map.get(viewMode).get(type).set(representationType, component); + }; +} + +/** + * Get the component used for rendering an item by type and viewMode (and optionally a representationType) + * @param type + * @param viewMode + * @param representationType + */ +export function getComponentByItemType(type: string, viewMode: string, representationType?: MetadataRepresentationType) { + if (hasNoValue(representationType)) { + representationType = NO_REPRESENTATION_TYPE; + } + if (hasNoValue(map.get(viewMode))) { + viewMode = DEFAULT_VIEW_MODE; + } + if (hasNoValue(map.get(viewMode).get(type))) { + type = DEFAULT_ITEM_TYPE; + } + let representationComponent = map.get(viewMode).get(type).get(representationType); + if (hasNoValue(representationComponent)) { + representationComponent = map.get(viewMode).get(type).get(DEFAULT_REPRESENTATION_TYPE); + } + if (hasNoValue(representationComponent)) { + representationComponent = map.get(viewMode).get(type).get(NO_REPRESENTATION_TYPE); + } + return representationComponent; +} diff --git a/src/app/shared/items/switcher/item-type-switcher.component.html b/src/app/shared/items/switcher/item-type-switcher.component.html new file mode 100644 index 0000000000000000000000000000000000000000..496535949583487659ecad5381b548de2514657e --- /dev/null +++ b/src/app/shared/items/switcher/item-type-switcher.component.html @@ -0,0 +1 @@ +<ng-container *ngComponentOutlet="getComponent(); injector: objectInjector;"></ng-container> diff --git a/src/app/shared/items/switcher/item-type-switcher.component.scss b/src/app/shared/items/switcher/item-type-switcher.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..45a533cd01d15e25526c4e8a9b39d083b00936a8 --- /dev/null +++ b/src/app/shared/items/switcher/item-type-switcher.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables'; diff --git a/src/app/shared/items/switcher/item-type-switcher.component.spec.ts b/src/app/shared/items/switcher/item-type-switcher.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b13abf2efca8bc1e787af1de5e3c3e06c2678e6 --- /dev/null +++ b/src/app/shared/items/switcher/item-type-switcher.component.spec.ts @@ -0,0 +1,90 @@ +import { ItemTypeSwitcherComponent } from './item-type-switcher.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { Item } from '../../../core/shared/item.model'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { RemoteData } from '../../../core/data/remote-data'; +import * as decorator from '../item-type-decorator'; +import { getComponentByItemType, ItemViewMode } from '../item-type-decorator'; +import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import createSpy = jasmine.createSpy; + +const relationType = 'type'; +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item' + } + ], + 'relationship.type': [ + { + language: 'en_US', + value: relationType + } + ] + } +}); +const mockItemMetadataRepresentation = Object.assign(new ItemMetadataRepresentation(), mockItem); +let viewMode = ItemViewMode.Full; + +describe('ItemTypeSwitcherComponent', () => { + let comp: ItemTypeSwitcherComponent; + let fixture: ComponentFixture<ItemTypeSwitcherComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ItemTypeSwitcherComponent ], + schemas: [ NO_ERRORS_SCHEMA ] + }).compileComponents(); // compile template and css + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemTypeSwitcherComponent); + comp = fixture.componentInstance; + comp.object = mockItem; + comp.viewMode = viewMode; + spyOnProperty(decorator, 'getComponentByItemType').and.returnValue(createSpy('getComponentByItemType')) + })); + + describe('when the injected object is of type Item', () => { + beforeEach(() => { + viewMode = ItemViewMode.Full; + comp.object = mockItem; + comp.viewMode = viewMode; + }); + + describe('when calling getComponent', () => { + beforeEach(() => { + comp.getComponent(); + }); + + it('should call getComponentByItemType with parameters type and viewMode', () => { + expect(decorator.getComponentByItemType).toHaveBeenCalledWith(relationType, viewMode); + }); + }); + }); + + describe('when the injected object is of type MetadataRepresentation', () => { + beforeEach(() => { + viewMode = ItemViewMode.Metadata; + comp.object = mockItemMetadataRepresentation; + comp.viewMode = viewMode; + }); + + describe('when calling getComponent', () => { + beforeEach(() => { + comp.getComponent(); + }); + + it('should call getComponentByItemType with parameters type, viewMode and representationType', () => { + expect(decorator.getComponentByItemType).toHaveBeenCalledWith(relationType, viewMode, mockItemMetadataRepresentation.representationType); + }); + }); + }); + +}); diff --git a/src/app/shared/items/switcher/item-type-switcher.component.ts b/src/app/shared/items/switcher/item-type-switcher.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..21a045b8f460ac83f53678696e0c1f0956edb86e --- /dev/null +++ b/src/app/shared/items/switcher/item-type-switcher.component.ts @@ -0,0 +1,67 @@ +import { Component, InjectionToken, Injector, Input, OnInit } from '@angular/core'; +import { SearchResult } from '../../../+search-page/search-result.model'; +import { Item } from '../../../core/shared/item.model'; +import { hasValue } from '../../empty.util'; +import { ItemSearchResult } from '../../object-collection/shared/item-search-result.model'; +import { getComponentByItemType } from '../item-type-decorator'; +import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; + +export const ITEM: InjectionToken<string> = new InjectionToken<string>('item'); + +@Component({ + selector: 'ds-item-type-switcher', + styleUrls: ['./item-type-switcher.component.scss'], + templateUrl: './item-type-switcher.component.html' +}) +/** + * Component for determining what component to use depending on the item's relationship type (relationship.type) + */ +export class ItemTypeSwitcherComponent implements OnInit { + /** + * The item or metadata to determine the component for + */ + @Input() object: Item | SearchResult<Item> | MetadataRepresentation; + + /** + * The preferred view-mode to display + */ + @Input() viewMode: string; + + /** + * The object injector used to inject the item into the child component + */ + objectInjector: Injector; + + constructor(private injector: Injector) { + } + + ngOnInit(): void { + this.objectInjector = Injector.create({ + providers: [{ provide: ITEM, useFactory: () => this.object, deps:[] }], + parent: this.injector + }); + + } + + /** + * Fetch the component depending on the item's relationship type + * @returns {string} + */ + getComponent(): string { + if (hasValue((this.object as any).representationType)) { + const metadataRepresentation = this.object as MetadataRepresentation; + return getComponentByItemType(metadataRepresentation.itemType, this.viewMode, metadataRepresentation.representationType); + } + + let item: Item; + if (hasValue((this.object as any).indexableObject)) { + const searchResult = this.object as ItemSearchResult; + item = searchResult.indexableObject; + } else { + item = this.object as Item; + } + + const type = item.firstMetadataValue('relationship.type'); + return getComponentByItemType(type, this.viewMode); + } +} diff --git a/src/app/shared/log-out/log-out.component.html b/src/app/shared/log-out/log-out.component.html index f3ceae00877f3453b60de4d7676ab80fee96b81e..d522fc6fb94bccf2ebda267fc96f3fc6919249c9 100644 --- a/src/app/shared/log-out/log-out.component.html +++ b/src/app/shared/log-out/log-out.component.html @@ -1,7 +1,6 @@ -<ds-loading *ngIf="(loading | async)"></ds-loading> -<div *ngIf="!(loading | async)" class="form-login px-4 py-3"> +<div class="form-login px-4"> - <div *ngIf="(error | async) && hasError" class="alert alert-danger" role="alert" @fadeOut>{{ error | async }}</div> + <div *ngIf="(error | async)" class="alert alert-danger" role="alert" @fadeOut>{{ error | async }}</div> <button class="btn btn-lg btn-primary btn-block mt-3" (click)="logOut()">{{"logout.form.submit" | translate}}</button> </div> diff --git a/src/app/shared/log-out/log-out.component.ts b/src/app/shared/log-out/log-out.component.ts index 9e8e7f78652a49039dde090461369f85603db202..6fa71caa32832b379855ba5d21fc876cee2602ce 100644 --- a/src/app/shared/log-out/log-out.component.ts +++ b/src/app/shared/log-out/log-out.component.ts @@ -1,21 +1,12 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; -// @ngrx +import { Observable } from 'rxjs'; import { select, Store } from '@ngrx/store'; -// actions import { LogOutAction } from '../../core/auth/auth.actions'; - -// reducers -import { - getLogOutError, - isAuthenticated, - isAuthenticationLoading, -} from '../../core/auth/selectors'; - +import { getLogOutError, } from '../../core/auth/selectors'; import { AppState } from '../../app.reducer'; -import { Observable } from 'rxjs'; import { fadeOut } from '../animations/fade'; @Component({ @@ -24,49 +15,28 @@ import { fadeOut } from '../animations/fade'; styleUrls: ['./log-out.component.scss'], animations: [fadeOut] }) -export class LogOutComponent implements OnDestroy, OnInit { +export class LogOutComponent implements OnInit { /** * The error if authentication fails. * @type {Observable<string>} */ public error: Observable<string>; - /** - * True if the logout is loading. - * @type {boolean} - */ - public loading: Observable<boolean>; - - /** - * Component state. - * @type {boolean} - */ - private alive = true; - /** * @constructor * @param {Store<State>} store + * @param {Router} router */ constructor(private router: Router, private store: Store<AppState>) { } - /** - * Lifecycle hook that is called when a directive, pipe or service is destroyed. - */ - public ngOnDestroy() { - this.alive = false; - } - /** * Lifecycle hook that is called after data-bound properties of a directive are initialized. */ ngOnInit() { // set error this.error = this.store.pipe(select(getLogOutError)); - - // set loading - this.loading = this.store.pipe(select(isAuthenticationLoading)); } /** 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 index 76856e57efc09ce2aaf2accacfa0e54c28624645..74e918c07b4806e300990f66d28b71754630125f 100644 --- a/src/app/shared/menu/menu-item/link-menu-item.component.html +++ b/src/app/shared/menu/menu-item/link-menu-item.component.html @@ -1 +1 @@ -<a class="nav-item nav-link" [routerLink]="getRouterLink()">{{item.text | translate}}</a> \ No newline at end of file +<a class="nav-item nav-link" [ngClass]="{'disabled': !hasLink}" [routerLink]="getRouterLink()">{{item.text | translate}}</a> \ No newline at end of file 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 index 02ce31843c377c230292498a8cf121348687ae23..4e1e70236bc286a46fc73141d3cb06eba9f879c5 100644 --- a/src/app/shared/menu/menu-item/link-menu-item.component.ts +++ b/src/app/shared/menu/menu-item/link-menu-item.component.ts @@ -1,8 +1,9 @@ -import { Component, Inject, Input } from '@angular/core'; +import { Component, Inject, Input, OnInit } 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'; +import { isNotEmpty } from '../../empty.util'; /** * Component that renders a menu section of type LINK @@ -12,13 +13,22 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; templateUrl: './link-menu-item.component.html' }) @rendersMenuItemForType(MenuItemType.LINK) -export class LinkMenuItemComponent { +export class LinkMenuItemComponent implements OnInit { item: LinkMenuItemModel; + hasLink: boolean; constructor(@Inject('itemModelProvider') item: LinkMenuItemModel, @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { this.item = item; } + ngOnInit(): void { + this.hasLink = isNotEmpty(this.item.link); + } + getRouterLink() { - return this.EnvConfig.ui.nameSpace + this.item.link; + if (this.hasLink) { + return this.EnvConfig.ui.nameSpace + this.item.link; + } + return undefined; } + } 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 index dd031a96e0b086c0610e96d4bc3861d3fd921b73..dbe6fdab6a6788273d5446c4f78c60280ea74dbd 100644 --- 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 @@ -1,5 +1,4 @@ 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'; diff --git a/src/app/shared/menu/menu.module.ts b/src/app/shared/menu/menu.module.ts index 7e900d18e662120fa5d274d169205b5570fa7fda..12d9b4001bbe64bc363f9a01f497d37016a6855a 100644 --- a/src/app/shared/menu/menu.module.ts +++ b/src/app/shared/menu/menu.module.ts @@ -6,6 +6,7 @@ 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'; +import { CommonModule } from '@angular/common'; const COMPONENTS = [ MenuSectionComponent, @@ -23,7 +24,8 @@ const ENTRY_COMPONENTS = [ const MODULES = [ TranslateModule, - RouterModule + RouterModule, + CommonModule ]; const PROVIDERS = [ 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 3cab439581f437ed71ef708f30dfc1d6b4bb23a6..6a05c9bf3688cdd1ffb22410ad65993747f83696 100644 --- a/src/app/shared/mocks/mock-remote-data-build.service.ts +++ b/src/app/shared/mocks/mock-remote-data-build.service.ts @@ -1,5 +1,5 @@ -import {of as observableOf, Observable } from 'rxjs'; -import { map, take } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { map } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { RemoteData } from '../../core/data/remote-data'; import { RequestEntry } from '../../core/data/request.reducer'; diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts index ce09f6d85e35e1c3b32afd89a85c0dfd8713ff72..9a320b749c1d98ce4cb70fdbb99ed1edb95f85ff 100644 --- a/src/app/shared/mocks/mock-request.service.ts +++ b/src/app/shared/mocks/mock-request.service.ts @@ -8,6 +8,7 @@ export function getMockRequestService(requestEntry$: Observable<RequestEntry> = generateRequestId: 'clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78', getByHref: requestEntry$, getByUUID: requestEntry$, + uriEncodeBody: jasmine.createSpy('uriEncodeBody'), /* tslint:disable:no-empty */ removeByHrefSubstring: () => {} /* tslint:enable:no-empty */ diff --git a/src/app/shared/mocks/mock-role-service.ts b/src/app/shared/mocks/mock-role-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..dad296f986bcdcd932638bb93fb2c310c9cd219d --- /dev/null +++ b/src/app/shared/mocks/mock-role-service.ts @@ -0,0 +1,51 @@ +import { Observable } from 'rxjs/internal/Observable'; +import { RoleType } from '../../core/roles/role-types'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; + +export class MockRoleService { + + _isSubmitter = new BehaviorSubject(true); + _isController = new BehaviorSubject(true); + _isAdmin = new BehaviorSubject(true); + + setSubmitter(isSubmitter: boolean) { + this._isSubmitter.next(isSubmitter); + } + + setController(isController: boolean) { + this._isController.next(isController); + } + + setAdmin(isAdmin: boolean) { + this._isAdmin.next(isAdmin); + } + + isSubmitter(): Observable<boolean> { + return this._isSubmitter; + } + + isController(): Observable<boolean> { + return this._isController; + } + + isAdmin(): Observable<boolean> { + return this._isAdmin; + } + + checkRole(role: RoleType): Observable<boolean> { + let check: Observable<boolean>; + switch (role) { + case RoleType.Submitter: + check = this.isSubmitter(); + break; + case RoleType.Controller: + check = this.isController(); + break; + case RoleType.Admin: + check = this.isAdmin(); + break; + } + + return check; + } +} diff --git a/src/app/shared/mocks/mock-router.ts b/src/app/shared/mocks/mock-router.ts index 929e2644e830a6651cba2cdd1a3d4de72daf6efe..fb475a04671aa6f83a16ad9a64ad35ff58d6d8c7 100644 --- a/src/app/shared/mocks/mock-router.ts +++ b/src/app/shared/mocks/mock-router.ts @@ -7,7 +7,10 @@ export class MockRouter { public events = observableOf({}); public routerState = { snapshot: { - url: '' + url: '', + root: { + queryParamMap: null + } } }; @@ -18,4 +21,12 @@ export class MockRouter { setRoute(route) { this.routerState.snapshot.url = route; } + + setParams(paramsMap) { + this.routerState.snapshot.root.queryParamMap = paramsMap; + } + + createUrlTree(commands, navExtras = {}) { + return {}; + } } diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html new file mode 100644 index 0000000000000000000000000000000000000000..3c41fdbb07cb458d63468c2bd4545f362cc7db8f --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html @@ -0,0 +1,8 @@ +<button type="button" + [className]="'btn btn-success ' + wrapperClass" + ngbTooltip="{{'submission.workflow.tasks.claimed.approve_help' | translate}}" + [disabled]="processingApprove" + (click)="confirmApprove()"> + <span *ngIf="processingApprove"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> + <span *ngIf="!processingApprove"><i class="fa fa-thumbs-up"></i> {{'submission.workflow.tasks.claimed.approve' | translate}}</span> +</button> diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.scss b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..552d31675e97710ff4193bf59945da36dfec9269 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { ClaimedTaskActionsApproveComponent } from './claimed-task-actions-approve.component'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; + +let component: ClaimedTaskActionsApproveComponent; +let fixture: ComponentFixture<ClaimedTaskActionsApproveComponent>; + +describe('ClaimedTaskActionsApproveComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ClaimedTaskActionsApproveComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedTaskActionsApproveComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClaimedTaskActionsApproveComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + it('should display approve button', () => { + const btn = fixture.debugElement.query(By.css('.btn-success')); + + expect(btn).toBeDefined(); + }); + + it('should display spin icon when approve is pending', () => { + component.processingApprove = true; + fixture.detectChanges(); + + const span = fixture.debugElement.query(By.css('.btn-success .fa-spin')); + + expect(span).toBeDefined(); + }); + + it('should emit approve event', () => { + spyOn(component.approve, 'emit'); + + component.confirmApprove(); + fixture.detectChanges(); + + expect(component.approve.emit).toHaveBeenCalled(); + }); + +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e7c0dab606edfd8542902a5cef155bff5c4ef4b --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts @@ -0,0 +1,32 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'ds-claimed-task-actions-approve', + styleUrls: ['./claimed-task-actions-approve.component.scss'], + templateUrl: './claimed-task-actions-approve.component.html', +}) + +export class ClaimedTaskActionsApproveComponent { + + /** + * A boolean representing if a reject operation is pending + */ + @Input() processingApprove: boolean; + + /** + * CSS classes to append to reject button + */ + @Input() wrapperClass: string; + + /** + * An event fired when a approve action is confirmed. + */ + @Output() approve: EventEmitter<any> = new EventEmitter<any>(); + + /** + * Emit approve event + */ + confirmApprove() { + this.approve.emit(); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html new file mode 100644 index 0000000000000000000000000000000000000000..4b9b93e7e3469ccf25b15bf5210f0745c31c041c --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html @@ -0,0 +1,20 @@ + + <a [class.disabled]="!(object.workflowitem | async)?.hasSucceeded" + class="btn btn-primary mt-1 mb-3" + ngbTooltip="{{'submission.workflow.tasks.claimed.edit_help' | translate}}" + [routerLink]="['/workflowitems/' + (object.workflowitem | async)?.payload.id + '/' + object.id + '/edit']" + role="button"> + <i class="fa fa-edit"></i> {{'submission.workflow.tasks.claimed.edit' | translate}} + </a> + + <ds-claimed-task-actions-approve [processingApprove]="(processingApprove$ | async)" + [wrapperClass]="'mt-1 mb-3'" + (approve)="approve()"></ds-claimed-task-actions-approve> + + <ds-claimed-task-actions-reject [processingReject]="(processingReject$ | async)" + [wrapperClass]="'mt-1 mb-3'" + (reject)="reject($event)"></ds-claimed-task-actions-reject> + + <ds-claimed-task-actions-return-to-pool [processingReturnToPool]="(processingReturnToPool$ | async)" + [wrapperClass]="'mt-1 mb-3'" + (returnToPool)="returnToPool()"></ds-claimed-task-actions-return-to-pool> diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.scss b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4144cb3760c4420285bc7d3e49152e83de7b48a9 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts @@ -0,0 +1,269 @@ +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { cold } from 'jasmine-marbles'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; +import { RouterStub } from '../../testing/router-stub'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { ClaimedTaskActionsComponent } from './claimed-task-actions.component'; +import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; +import { Workflowitem } from '../../../core/submission/models/workflowitem.model'; + +let component: ClaimedTaskActionsComponent; +let fixture: ComponentFixture<ClaimedTaskActionsComponent>; + +let mockObject: ClaimedTask; +let notificationsServiceStub: NotificationsServiceStub; +let router: RouterStub; + +const mockDataService = jasmine.createSpyObj('PoolTaskDataService', { + approveTask: jasmine.createSpy('approveTask'), + rejectTask: jasmine.createSpy('rejectTask'), + returnToPoolTask: jasmine.createSpy('returnToPoolTask'), +}); + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = new RemoteData(false, false, true, null, item); +const workflowitem = Object.assign(new Workflowitem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' }); + +describe('ClaimedTaskActionsComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ClaimedTaskActionsComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: ClaimedTaskDataService, useValue: mockDataService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedTaskActionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClaimedTaskActionsComponent); + component = fixture.componentInstance; + component.object = mockObject; + notificationsServiceStub = TestBed.get(NotificationsService); + router = TestBed.get(Router); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + it('should init objects properly', () => { + component.object = null; + component.initObjects(mockObject); + + expect(component.object).toEqual(mockObject); + + expect(component.workflowitem$).toBeObservable(cold('(b|)', { + b: rdWorkflowitem.payload + })) + }); + + it('should display edit task button', () => { + const btn = fixture.debugElement.query(By.css('.btn-info')); + + expect(btn).toBeDefined(); + }); + + it('should call approveTask method when approving a task', fakeAsync(() => { + spyOn(component, 'reload'); + mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.approve(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(mockDataService.approveTask).toHaveBeenCalledWith(mockObject.id); + }); + + })); + + it('should display a success notification on approve success', async(() => { + spyOn(component, 'reload'); + mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.approve(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + })); + + it('should reload page on approve success', async(() => { + spyOn(router, 'navigateByUrl'); + router.url = 'test.url/test'; + mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.approve(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); + }); + })); + + it('should display an error notification on approve failure', async(() => { + mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: false})); + + component.approve(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + })); + + it('should call rejectTask method when rejecting a task', fakeAsync(() => { + spyOn(component, 'reload'); + mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.reject('test reject'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(mockDataService.rejectTask).toHaveBeenCalledWith('test reject', mockObject.id); + }); + + })); + + it('should display a success notification on reject success', async(() => { + spyOn(component, 'reload'); + mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.reject('test reject'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + })); + + it('should reload page on reject success', async(() => { + spyOn(router, 'navigateByUrl'); + router.url = 'test.url/test'; + mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.reject('test reject'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); + }); + })); + + it('should display an error notification on reject failure', async(() => { + mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: false})); + + component.reject('test reject'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + })); + + it('should call returnToPoolTask method when returning a task to pool', fakeAsync(() => { + spyOn(component, 'reload'); + mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.returnToPool(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(mockDataService.returnToPoolTask).toHaveBeenCalledWith( mockObject.id); + }); + + })); + + it('should display a success notification on return to pool success', async(() => { + spyOn(component, 'reload'); + mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.returnToPool(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + })); + + it('should reload page on return to pool success', async(() => { + spyOn(router, 'navigateByUrl'); + router.url = 'test.url/test'; + mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.returnToPool(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); + }); + })); + + it('should display an error notification on return to pool failure', async(() => { + mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: false})); + + component.returnToPool(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + })); +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a7669f5b11301682ceb9581119e5b334bc7fca4 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts @@ -0,0 +1,123 @@ +import { Component, Injector, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; +import { isNotUndefined } from '../../empty.util'; +import { Workflowitem } from '../../../core/submission/models/workflowitem.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { MyDSpaceActionsComponent } from '../mydspace-actions'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { NotificationsService } from '../../notifications/notifications.service'; + +/** + * This component represents mydspace actions related to ClaimedTask object. + */ +@Component({ + selector: 'ds-claimed-task-actions', + styleUrls: ['./claimed-task-actions.component.scss'], + templateUrl: './claimed-task-actions.component.html', +}) +export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<ClaimedTask, ClaimedTaskDataService> implements OnInit { + + /** + * The ClaimedTask object + */ + @Input() object: ClaimedTask; + + /** + * The workflowitem object that belonging to the ClaimedTask object + */ + public workflowitem$: Observable<Workflowitem>; + + /** + * A boolean representing if an approve operation is pending + */ + public processingApprove$ = new BehaviorSubject<boolean>(false); + + /** + * A boolean representing if a reject operation is pending + */ + public processingReject$ = new BehaviorSubject<boolean>(false); + + /** + * A boolean representing if a return to pool operation is pending + */ + public processingReturnToPool$ = new BehaviorSubject<boolean>(false); + + /** + * Initialize instance variables + * + * @param {Injector} injector + * @param {Router} router + * @param {NotificationsService} notificationsService + * @param {TranslateService} translate + */ + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { + super(ResourceType.ClaimedTask, injector, router, notificationsService, translate); + } + + /** + * Initialize objects + */ + ngOnInit() { + this.initObjects(this.object); + } + + /** + * Init the ClaimedTask and Workflowitem objects + * + * @param {PoolTask} object + */ + initObjects(object: ClaimedTask) { + this.object = object; + this.workflowitem$ = (this.object.workflowitem as Observable<RemoteData<Workflowitem>>).pipe( + filter((rd: RemoteData<Workflowitem>) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))), + map((rd: RemoteData<Workflowitem>) => rd.payload)); + } + + /** + * Approve the task. + */ + approve() { + this.processingApprove$.next(true); + this.objectDataService.approveTask(this.object.id) + .subscribe((res: ProcessTaskResponse) => { + this.processingApprove$.next(false); + this.handleActionResponse(res.hasSucceeded); + }); + } + + /** + * Reject the task. + */ + reject(reason) { + this.processingReject$.next(true); + this.objectDataService.rejectTask(reason, this.object.id) + .subscribe((res: ProcessTaskResponse) => { + this.processingReject$.next(false); + this.handleActionResponse(res.hasSucceeded); + }); + } + + /** + * Return task to the pool. + */ + returnToPool() { + this.processingReturnToPool$.next(true); + this.objectDataService.returnToPoolTask(this.object.id) + .subscribe((res: ProcessTaskResponse) => { + this.processingReturnToPool$.next(false); + this.handleActionResponse(res.hasSucceeded); + }); + } + +} diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html new file mode 100644 index 0000000000000000000000000000000000000000..91edee66bd5654032d60c5cd036ca4bcc86e2d0e --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html @@ -0,0 +1,38 @@ +<ng-template #rejectTipContent><p [innerHTML]="'submission.workflow.tasks.claimed.reject_help' | translate"></p></ng-template> +<button [className]="'btn btn-danger ' + wrapperClass" + [ngbTooltip]="rejectTipContent" + [disabled]="processingReject" + (click)="openRejectModal(rejectModal)" > + <span *ngIf="processingReject"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> + <span *ngIf="!processingReject"><i class="fa fa-trash"></i> {{'submission.workflow.tasks.claimed.reject.submit' | translate}}</span> +</button> + +<ng-template #rejectModal let-c="close" let-d="dismiss"> + <div class="modal-header"> + <h4 class="modal-title">{{'submission.workflow.tasks.claimed.reject.reason.title' | translate}}</h4> + <button type="button" + class="close" + aria-label="Close" + (click)="d('Cross click')"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <div class="alert alert-info" role="alert"> + {{'submission.workflow.tasks.claimed.reject.reason.info' | translate}} + </div> + <form (ngSubmit)="confirmReject(rejectModal);" [formGroup]="rejectForm" > + <textarea style="width: 100%" + formControlName="reason" + rows="4" + placeholder="{{'submission.workflow.tasks.claimed.reject.reason.placeholder' | translate}}"></textarea> + <button id="btn-chat" + class="btn btn-danger btn-lg btn-block mt-3" + [disabled]="!rejectForm.valid || processingReject" + type="submit"> + <span *ngIf="processingReject"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> + <span *ngIf="!processingReject">{{'submission.workflow.tasks.claimed.reject.reason.submit' | translate}}</span> + </button> + </form> + </div> +</ng-template> diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.scss b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7e0b53748bd3ff5ce6e3dd3ccc77b54e9522c89 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; + +import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { ClaimedTaskActionsRejectComponent } from './claimed-task-actions-reject.component'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; + +let component: ClaimedTaskActionsRejectComponent; +let fixture: ComponentFixture<ClaimedTaskActionsRejectComponent>; +let formBuilder: FormBuilder; +let modalService: NgbModal; + +describe('ClaimedTaskActionsRejectComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbModule.forRoot(), + ReactiveFormsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ClaimedTaskActionsRejectComponent], + providers: [ + FormBuilder, + NgbModal + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedTaskActionsRejectComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClaimedTaskActionsRejectComponent); + component = fixture.componentInstance; + formBuilder = TestBed.get(FormBuilder); + modalService = TestBed.get(NgbModal); + component.modalRef = modalService.open('ok'); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + modalService = null; + formBuilder = null; + }); + + it('should init reject form properly', () => { + expect(component.rejectForm).toBeDefined(); + expect(component.rejectForm instanceof FormGroup).toBeTruthy(); + expect(component.rejectForm.controls.reason).toBeDefined(); + }); + + it('should display reject button', () => { + const btn = fixture.debugElement.query(By.css('.btn-danger')); + + expect(btn).toBeDefined(); + }); + + it('should display spin icon when reject is pending', () => { + component.processingReject = true; + fixture.detectChanges(); + + const span = fixture.debugElement.query(By.css('.btn-danger .fa-spin')); + + expect(span).toBeDefined(); + }); + + it('should call openRejectModal on reject button click', fakeAsync(() => { + spyOn(component.rejectForm, 'reset'); + const btn = fixture.debugElement.query(By.css('.btn-danger')); + btn.nativeElement.click(); + fixture.detectChanges(); + + expect(component.rejectForm.reset).toHaveBeenCalled(); + expect(component.modalRef).toBeDefined(); + + component.modalRef.close() + })); + + it('should call confirmReject on form submit', fakeAsync(() => { + spyOn(component.reject, 'emit'); + + const btn = fixture.debugElement.query(By.css('.btn-danger')); + btn.nativeElement.click(); + fixture.detectChanges(); + + expect(component.modalRef).toBeDefined(); + + const form = ((document as any).querySelector('form')); + form.dispatchEvent(new Event('ngSubmit')); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(component.reject.emit).toHaveBeenCalled(); + }); + + })); +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..b66c10469523a755f0fd2484a8a235e7e6de7f75 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts @@ -0,0 +1,78 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'ds-claimed-task-actions-reject', + styleUrls: ['./claimed-task-actions-reject.component.scss'], + templateUrl: './claimed-task-actions-reject.component.html', +}) + +export class ClaimedTaskActionsRejectComponent implements OnInit { + + /** + * A boolean representing if a reject operation is pending + */ + @Input() processingReject: boolean; + + /** + * CSS classes to append to reject button + */ + @Input() wrapperClass: string; + + /** + * An event fired when a reject action is confirmed. + * Event's payload equals to reject reason. + */ + @Output() reject: EventEmitter<string> = new EventEmitter<string>(); + + /** + * The reject form group + */ + public rejectForm: FormGroup; + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + /** + * Initialize instance variables + * + * @param {FormBuilder} formBuilder + * @param {NgbModal} modalService + */ + constructor(private formBuilder: FormBuilder, private modalService: NgbModal) { + } + + /** + * Initialize form + */ + ngOnInit() { + this.rejectForm = this.formBuilder.group({ + reason: ['', Validators.required] + }); + + } + + /** + * Close modal and emit reject event + */ + confirmReject() { + this.processingReject = true; + this.modalRef.close('Send Button'); + const reason = this.rejectForm.get('reason').value; + this.reject.emit(reason); + } + + /** + * Open modal + * + * @param content + */ + openRejectModal(content: any) { + this.rejectForm.reset(); + this.modalRef = this.modalService.open(content); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html new file mode 100644 index 0000000000000000000000000000000000000000..702ce75e7f4b49fc1abf7c2f1ac19469c88f01ca --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html @@ -0,0 +1,8 @@ +<button type="button" + [className]="'btn btn-secondary ' + wrapperClass" + ngbTooltip="{{'submission.workflow.tasks.claimed.return_help' | translate}}" + [disabled]="processingReturnToPool" + (click)="confirmReturnToPool()"> + <span *ngIf="processingReturnToPool"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> + <span *ngIf="!processingReturnToPool"><i class="fa fa-undo"></i> {{'submission.workflow.tasks.claimed.return' | translate}}</span> +</button> diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.scss b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d461d9e055c4a9c32db12b01224b2a199b16a914 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { ClaimedTaskActionsReturnToPoolComponent } from './claimed-task-actions-return-to-pool.component'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; + +let component: ClaimedTaskActionsReturnToPoolComponent; +let fixture: ComponentFixture<ClaimedTaskActionsReturnToPoolComponent>; + +describe('ClaimedTaskActionsReturnToPoolComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ClaimedTaskActionsReturnToPoolComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedTaskActionsReturnToPoolComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClaimedTaskActionsReturnToPoolComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + it('should display return to pool button', () => { + const btn = fixture.debugElement.query(By.css('.btn-secondary')); + + expect(btn).toBeDefined(); + }); + + it('should display spin icon when return to pool action is pending', () => { + component.processingReturnToPool = true; + fixture.detectChanges(); + + const span = fixture.debugElement.query(By.css('.btn-secondary .fa-spin')); + + expect(span).toBeDefined(); + }); + + it('should emit return to pool event', () => { + spyOn(component.returnToPool, 'emit'); + + component.confirmReturnToPool(); + fixture.detectChanges(); + + expect(component.returnToPool.emit).toHaveBeenCalled(); + }); + +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..1dfe91eb5b88dfd5a14ec16c874fbb1fa653da23 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts @@ -0,0 +1,32 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'ds-claimed-task-actions-return-to-pool', + styleUrls: ['./claimed-task-actions-return-to-pool.component.scss'], + templateUrl: './claimed-task-actions-return-to-pool.component.html', +}) + +export class ClaimedTaskActionsReturnToPoolComponent { + + /** + * A boolean representing if a return to pool operation is pending + */ + @Input() processingReturnToPool: boolean; + + /** + * CSS classes to append to return to pool button + */ + @Input() wrapperClass: string; + + /** + * An event fired when a return to pool action is confirmed. + */ + @Output() returnToPool: EventEmitter<any> = new EventEmitter<any>(); + + /** + * Emit returnToPool event + */ + confirmReturnToPool() { + this.returnToPool.emit(); + } +} diff --git a/src/app/shared/mydspace-actions/item/item-actions.component.html b/src/app/shared/mydspace-actions/item/item-actions.component.html new file mode 100644 index 0000000000000000000000000000000000000000..91993466b61e1ca3ba690f40192159e4253606f1 --- /dev/null +++ b/src/app/shared/mydspace-actions/item/item-actions.component.html @@ -0,0 +1,5 @@ +<button class="btn btn-primary mt-1 mb-3" + ngbTooltip="{{'submission.workflow.generic.view-help' | translate}}" + [routerLink]="['/items/' + object.id]"> + <i class="fa fa-info-circle"></i> {{"submission.workflow.generic.view" | translate}} +</button> diff --git a/src/app/shared/mydspace-actions/item/item-actions.component.scss b/src/app/shared/mydspace-actions/item/item-actions.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/item/item-actions.component.spec.ts b/src/app/shared/mydspace-actions/item/item-actions.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..72be122c8f084ff035dd5e1f1dea576cbf77877a --- /dev/null +++ b/src/app/shared/mydspace-actions/item/item-actions.component.spec.ts @@ -0,0 +1,96 @@ +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; +import { RouterStub } from '../../testing/router-stub'; +import { Item } from '../../../core/shared/item.model'; +import { ItemActionsComponent } from './item-actions.component'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; + +let component: ItemActionsComponent; +let fixture: ComponentFixture<ItemActionsComponent>; + +let mockObject: Item; + +const mockDataService = {}; + +mockObject = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); + +describe('ItemActionsComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ItemActionsComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: Router, useValue: new RouterStub() }, + { provide: ItemDataService, useValue: mockDataService }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemActionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemActionsComponent); + component = fixture.componentInstance; + component.object = mockObject; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + it('should init object properly', () => { + component.object = null; + component.initObjects(mockObject); + + expect(component.object).toEqual(mockObject); + }); + +}); diff --git a/src/app/shared/mydspace-actions/item/item-actions.component.ts b/src/app/shared/mydspace-actions/item/item-actions.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..0760fe54e03d600543dca7e1de589fc1300ee706 --- /dev/null +++ b/src/app/shared/mydspace-actions/item/item-actions.component.ts @@ -0,0 +1,52 @@ +import { Component, Injector, Input } from '@angular/core'; +import { Router } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; + +import { MyDSpaceActionsComponent } from '../mydspace-actions'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { NotificationsService } from '../../notifications/notifications.service'; + +/** + * This component represents mydspace actions related to Item object. + */ +@Component({ + selector: 'ds-item-actions', + styleUrls: ['./item-actions.component.scss'], + templateUrl: './item-actions.component.html', +}) + +export class ItemActionsComponent extends MyDSpaceActionsComponent<Item, ItemDataService> { + + /** + * The Item object + */ + @Input() object: Item; + + /** + * Initialize instance variables + * + * @param {Injector} injector + * @param {Router} router + * @param {NotificationsService} notificationsService + * @param {TranslateService} translate + */ + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { + super(ResourceType.Item, injector, router, notificationsService, translate); + } + + /** + * Init the target object + * + * @param {Item} object + */ + initObjects(object: Item) { + this.object = object; + } + +} diff --git a/src/app/shared/mydspace-actions/mydspace-actions-service.factory.ts b/src/app/shared/mydspace-actions/mydspace-actions-service.factory.ts new file mode 100644 index 0000000000000000000000000000000000000000..7aa948f68905a84b1b7df59473239e92bad91128 --- /dev/null +++ b/src/app/shared/mydspace-actions/mydspace-actions-service.factory.ts @@ -0,0 +1,36 @@ +import { DataService } from '../../core/data/data.service'; +import { ResourceType } from '../../core/shared/resource-type'; +import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; +import { ClaimedTaskDataService } from '../../core/tasks/claimed-task-data.service'; +import { PoolTaskDataService } from '../../core/tasks/pool-task-data.service'; +import { WorkflowitemDataService } from '../../core/submission/workflowitem-data.service'; +import { CacheableObject } from '../../core/cache/object-cache.reducer'; +import { ItemDataService } from '../../core/data/item-data.service'; + +/** + * Class to return DataService for given ResourceType + */ +export class MydspaceActionsServiceFactory<T extends CacheableObject, TService extends DataService<T>> { + public getConstructor(type: ResourceType): TService { + switch (type) { + case ResourceType.Item: { + return ItemDataService as any; + } + case ResourceType.Workspaceitem: { + return WorkspaceitemDataService as any; + } + case ResourceType.Workflowitem: { + return WorkflowitemDataService as any; + } + case ResourceType.ClaimedTask: { + return ClaimedTaskDataService as any; + } + case ResourceType.PoolTask: { + return PoolTaskDataService as any; + } + default: { + return undefined; + } + } + } +} diff --git a/src/app/shared/mydspace-actions/mydspace-actions.ts b/src/app/shared/mydspace-actions/mydspace-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e465644c36fac4c348614f741968a9dd95aa6f2 --- /dev/null +++ b/src/app/shared/mydspace-actions/mydspace-actions.ts @@ -0,0 +1,99 @@ +import { Router } from '@angular/router'; +import { Injector, Input } from '@angular/core'; + +import { find } from 'rxjs/operators'; + +import { MydspaceActionsServiceFactory } from './mydspace-actions-service.factory'; +import { RemoteData } from '../../core/data/remote-data'; +import { DataService } from '../../core/data/data.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ResourceType } from '../../core/shared/resource-type'; +import { NotificationOptions } from '../notifications/models/notification-options.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Abstract class for all different representations of mydspace actions + */ +export abstract class MyDSpaceActionsComponent<T extends DSpaceObject, TService extends DataService<T>> { + + /** + * The target mydspace object + */ + @Input() abstract object: T; + + /** + * Instance of DataService realted to mydspace object + */ + protected objectDataService: TService; + + /** + * Initialize instance variables + * + * @param {ResourceType} objectType + * @param {Injector} injector + * @param {Router} router + * @param {NotificationsService} notificationsService + * @param {TranslateService} translate + */ + constructor( + protected objectType: ResourceType, + protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { + const factory = new MydspaceActionsServiceFactory<T, TService>(); + this.objectDataService = injector.get(factory.getConstructor(objectType)); + } + + /** + * Abstract method called to init the target object + * + * @param {T} object + */ + abstract initObjects(object: T): void; + + /** + * Refresh current page + */ + reload(): void { + // override the route reuse strategy + this.router.routeReuseStrategy.shouldReuseRoute = () => { + return false; + }; + this.router.navigated = false; + const url = decodeURIComponent(this.router.url); + this.router.navigateByUrl(url); + } + + /** + * Override the target object with a refreshed one + */ + refresh(): void { + // find object by id + this.objectDataService.findById(this.object.id).pipe( + find((rd: RemoteData<T>) => rd.hasSucceeded) + ).subscribe((rd: RemoteData<T>) => { + this.initObjects(rd.payload as T); + }); + } + + /** + * Handle action response and show properly notification + * + * @param result + * true on success, false otherwise + */ + handleActionResponse(result: boolean): void { + if (result) { + this.reload(); + this.notificationsService.success(null, + this.translate.get('submission.workflow.tasks.generic.success'), + new NotificationOptions(5000, false)); + } else { + this.notificationsService.error(null, + this.translate.get('submission.workflow.tasks.generic.error'), + new NotificationOptions(20000, true)); + } + } +} diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html new file mode 100644 index 0000000000000000000000000000000000000000..6f4ffffad362addeb91ae0000f4fbccb4ad0458f --- /dev/null +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html @@ -0,0 +1,8 @@ +<button type="button" + class="btn btn-info mt-1 mb-3" + ngbTooltip="{{'submission.workflow.tasks.pool.claim_help' | translate}}" + [disabled]="(processingClaim$ | async)" + (click)="claim()"> + <span *ngIf="(processingClaim$ | async)"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> + <span *ngIf="!(processingClaim$ | async)"><i class="fas fa-hand-paper"></i> {{'submission.workflow.tasks.pool.claim' | translate}}</span> +</button> diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.scss b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c0e8e71facba6c0836b1561deb1cf13446f3dc2 --- /dev/null +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts @@ -0,0 +1,170 @@ +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { cold } from 'jasmine-marbles'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; +import { RouterStub } from '../../testing/router-stub'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { PoolTaskDataService } from '../../../core/tasks/pool-task-data.service'; +import { PoolTaskActionsComponent } from './pool-task-actions.component'; +import { PoolTask } from '../../../core/tasks/models/pool-task-object.model'; +import { Workflowitem } from '../../../core/submission/models/workflowitem.model'; + +let component: PoolTaskActionsComponent; +let fixture: ComponentFixture<PoolTaskActionsComponent>; + +let mockObject: PoolTask; +let notificationsServiceStub: NotificationsServiceStub; +let router: RouterStub; + +const mockDataService = jasmine.createSpyObj('PoolTaskDataService', { + claimTask: jasmine.createSpy('claimTask') +}); + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = new RemoteData(false, false, true, null, item); +const workflowitem = Object.assign(new Workflowitem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' }); + +describe('PoolTaskActionsComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [PoolTaskActionsComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: PoolTaskDataService, useValue: mockDataService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PoolTaskActionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PoolTaskActionsComponent); + component = fixture.componentInstance; + component.object = mockObject; + notificationsServiceStub = TestBed.get(NotificationsService); + router = TestBed.get(Router); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + it('should init objects properly', () => { + component.object = null; + component.initObjects(mockObject); + + expect(component.object).toEqual(mockObject); + + expect(component.workflowitem$).toBeObservable(cold('(b|)', { + b: rdWorkflowitem.payload + })) + }); + + it('should display claim task button', () => { + const btn = fixture.debugElement.query(By.css('.btn-info')); + + expect(btn).toBeDefined(); + }); + + it('should call claimTask method on claim', fakeAsync(() => { + spyOn(component, 'reload'); + mockDataService.claimTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.claim(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(mockDataService.claimTask).toHaveBeenCalledWith(mockObject.id); + }); + + })); + + it('should display a success notification on claim success', async(() => { + spyOn(component, 'reload'); + mockDataService.claimTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.claim(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + })); + + it('should reload page on claim success', async(() => { + spyOn(router, 'navigateByUrl'); + router.url = 'test.url/test'; + mockDataService.claimTask.and.returnValue(observableOf({hasSucceeded: true})); + + component.claim(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); + }); + })); + + it('should display an error notification on claim failure', async(() => { + mockDataService.claimTask.and.returnValue(observableOf({hasSucceeded: false})); + + component.claim(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + })); + +}); diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd8f3f1a373a2b3e995add3ccf4b2948ceec2cc2 --- /dev/null +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts @@ -0,0 +1,89 @@ +import { Component, Injector, Input } from '@angular/core'; +import { Router } from '@angular/router'; + +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { Workflowitem } from '../../../core/submission/models/workflowitem.model'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PoolTask } from '../../../core/tasks/models/pool-task-object.model'; +import { PoolTaskDataService } from '../../../core/tasks/pool-task-data.service'; +import { isNotUndefined } from '../../empty.util'; +import { MyDSpaceActionsComponent } from '../mydspace-actions'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { NotificationsService } from '../../notifications/notifications.service'; + +/** + * This component represents mydspace actions related to PoolTask object. + */ +@Component({ + selector: 'ds-pool-task-actions', + styleUrls: ['./pool-task-actions.component.scss'], + templateUrl: './pool-task-actions.component.html', +}) +export class PoolTaskActionsComponent extends MyDSpaceActionsComponent<PoolTask, PoolTaskDataService> { + + /** + * The PoolTask object + */ + @Input() object: PoolTask; + + /** + * A boolean representing if a claim operation is pending + * @type {BehaviorSubject<boolean>} + */ + public processingClaim$ = new BehaviorSubject<boolean>(false); + + /** + * The workflowitem object that belonging to the PoolTask object + */ + public workflowitem$: Observable<Workflowitem>; + + /** + * Initialize instance variables + * + * @param {Injector} injector + * @param {Router} router + * @param {NotificationsService} notificationsService + * @param {TranslateService} translate + */ + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { + super(ResourceType.PoolTask, injector, router, notificationsService, translate); + } + + /** + * Initialize objects + */ + ngOnInit() { + this.initObjects(this.object); + } + + /** + * Init the PoolTask and Workflowitem objects + * + * @param {PoolTask} object + */ + initObjects(object: PoolTask) { + this.object = object; + this.workflowitem$ = (this.object.workflowitem as Observable<RemoteData<Workflowitem>>).pipe( + filter((rd: RemoteData<Workflowitem>) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))), + map((rd: RemoteData<Workflowitem>) => rd.payload)); + } + + /** + * Claim the task. + */ + claim() { + this.processingClaim$.next(true); + this.objectDataService.claimTask(this.object.id) + .subscribe((res: ProcessTaskResponse) => { + this.handleActionResponse(res.hasSucceeded); + this.processingClaim$.next(false); + }); + } +} diff --git a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.html b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.html new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.scss b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.spec.ts b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7533565afeb470cd513a80d93cd318e4e3152065 --- /dev/null +++ b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.spec.ts @@ -0,0 +1,98 @@ +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; +import { RouterStub } from '../../testing/router-stub'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { Workflowitem } from '../../../core/submission/models/workflowitem.model'; +import { WorkflowitemActionsComponent } from './workflowitem-actions.component'; +import { WorkflowitemDataService } from '../../../core/submission/workflowitem-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; + +let component: WorkflowitemActionsComponent; +let fixture: ComponentFixture<WorkflowitemActionsComponent>; + +let mockObject: Workflowitem; + +const mockDataService = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rd = new RemoteData(false, false, true, null, item); +mockObject = Object.assign(new Workflowitem(), { item: observableOf(rd), id: '1234', uuid: '1234' }); + +describe('WorkflowitemActionsComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [WorkflowitemActionsComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: Router, useValue: new RouterStub() }, + { provide: WorkflowitemDataService, useValue: mockDataService }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(WorkflowitemActionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkflowitemActionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + it('should init object properly', () => { + component.initObjects(mockObject); + + expect(component.object).toEqual(mockObject); + }); + +}); diff --git a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.ts b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6304bf5d400a736cb5ac87b1046c231c1caf566 --- /dev/null +++ b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.ts @@ -0,0 +1,51 @@ +import { Component, Injector, Input } from '@angular/core'; +import { Router } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; + +import { MyDSpaceActionsComponent } from '../mydspace-actions'; +import { Workflowitem } from '../../../core/submission/models/workflowitem.model'; +import { WorkflowitemDataService } from '../../../core/submission/workflowitem-data.service'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { NotificationsService } from '../../notifications/notifications.service'; + +/** + * This component represents mydspace actions related to Workflowitem object. + */ +@Component({ + selector: 'ds-workflowitem-actions', + styleUrls: ['./workflowitem-actions.component.scss'], + templateUrl: './workflowitem-actions.component.html', +}) +export class WorkflowitemActionsComponent extends MyDSpaceActionsComponent<Workflowitem, WorkflowitemDataService> { + + /** + * The Workflowitem object + */ + @Input() object: Workflowitem; + + /** + * Initialize instance variables + * + * @param {Injector} injector + * @param {Router} router + * @param {NotificationsService} notificationsService + * @param {TranslateService} translate + */ + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { + super(ResourceType.Workflowitem, injector, router, notificationsService, translate); + } + + /** + * Init the target object + * + * @param {Workflowitem} object + */ + initObjects(object: Workflowitem) { + this.object = object; + } + +} diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.html b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.html new file mode 100644 index 0000000000000000000000000000000000000000..6c3a047f73897cda0c521708987fc5c17b396bc9 --- /dev/null +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.html @@ -0,0 +1,30 @@ +<a class="btn btn-primary mt-1 mb-3" + ngbTooltip="{{'submission.workflow.generic.edit-help' | translate}}" + [routerLink]="['/workspaceitems/' + object.id + '/edit']" + role="button"> + <i class="fa fa-edit"></i> {{'submission.workflow.generic.edit' | translate}} +</a> + +<button type="button" + class="btn btn-danger mt-1 mb-3" + ngbTooltip="{{'submission.workflow.generic.delete-help' | translate}}" + (click)="$event.preventDefault();confirmDiscard(content)"> + <span *ngIf="(processingDelete$ | async)"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> + <span *ngIf="!(processingDelete$ | async)"><i class="fa fa-trash"></i> {{'submission.workflow.generic.delete' | translate}}</span> +</button> + +<ng-template #content let-c="close" let-d="dismiss"> + <div class="modal-header"> + <h4 class="modal-title text-danger">{{'submission.general.discard.confirm.title' | translate}}</h4> + <button type="button" class="close" aria-label="Close" (click)="d('cancel')"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <p>{{'submission.general.discard.confirm.info' | translate}}</p> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" (click)="c('cancel')">{{'submission.general.discard.confirm.cancel' | translate}}</button> + <button type="button" class="btn btn-danger" (click)="c('ok')">{{'submission.general.discard.confirm.submit' | translate}}</button> + </div> +</ng-template> diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.scss b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec8bc4a11c2cf8483b85d26e9b5794bd6f4f665c --- /dev/null +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts @@ -0,0 +1,163 @@ +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; +import { RouterStub } from '../../testing/router-stub'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { Workspaceitem } from '../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemActionsComponent } from './workspaceitem-actions.component'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; + +let component: WorkspaceitemActionsComponent; +let fixture: ComponentFixture<WorkspaceitemActionsComponent>; + +let mockObject: Workspaceitem; +let notificationsServiceStub: NotificationsServiceStub; + +const mockDataService = jasmine.createSpyObj('WorkspaceitemDataService', { + delete: jasmine.createSpy('delete') +}); + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rd = new RemoteData(false, false, true, null, item); +mockObject = Object.assign(new Workspaceitem(), { item: observableOf(rd), id: '1234', uuid: '1234' }); + +describe('WorkspaceitemActionsComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbModule.forRoot(), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [WorkspaceitemActionsComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: WorkspaceitemDataService, useValue: mockDataService }, + NgbModal + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(WorkspaceitemActionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkspaceitemActionsComponent); + component = fixture.componentInstance; + component.object = mockObject; + notificationsServiceStub = TestBed.get(NotificationsService); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + it('should init object properly', () => { + component.object = null; + component.initObjects(mockObject); + + expect(component.object).toEqual(mockObject); + }); + + it('should display edit button', () => { + const btn = fixture.debugElement.query(By.css('.btn-primary')); + + expect(btn).toBeDefined(); + }); + + it('should display delete button', () => { + const btn = fixture.debugElement.query(By.css('.btn-danger')); + + expect(btn).toBeDefined(); + }); + + it('should call confirmDiscard on discard confirmation', fakeAsync(() => { + mockDataService.delete.and.returnValue(observableOf(true)); + spyOn(component, 'reload'); + const btn = fixture.debugElement.query(By.css('.btn-danger')); + btn.nativeElement.click(); + fixture.detectChanges(); + + const confirmBtn: any = ((document as any).querySelector('.modal-footer .btn-danger')); + confirmBtn.click(); + + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(mockDataService.delete).toHaveBeenCalledWith(mockObject); + }); + + })); + + it('should display a success notification on delete success', async(() => { + spyOn((component as any).modalService, 'open').and.returnValue({result: Promise.resolve('ok')}); + mockDataService.delete.and.returnValue(observableOf(true)); + spyOn(component, 'reload'); + + component.confirmDiscard('ok'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + })); + + it('should display an error notification on delete failure', async(() => { + spyOn((component as any).modalService, 'open').and.returnValue({result: Promise.resolve('ok')}); + mockDataService.delete.and.returnValue(observableOf(false)); + spyOn(component, 'reload'); + + component.confirmDiscard('ok'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + })); +}); diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..cea4c3746e321a96a833a945c1ab066a1f15f748 --- /dev/null +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts @@ -0,0 +1,79 @@ +import { Component, Injector, Input } from '@angular/core'; +import { Router } from '@angular/router'; + +import { BehaviorSubject } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; + +import { Workspaceitem } from '../../../core/submission/models/workspaceitem.model'; +import { MyDSpaceActionsComponent } from '../mydspace-actions'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { NotificationsService } from '../../notifications/notifications.service'; + +/** + * This component represents mydspace actions related to Workspaceitem object. + */ +@Component({ + selector: 'ds-workspaceitem-actions', + styleUrls: ['./workspaceitem-actions.component.scss'], + templateUrl: './workspaceitem-actions.component.html', +}) +export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Workspaceitem, WorkspaceitemDataService> { + + /** + * The workspaceitem object + */ + @Input() object: Workspaceitem; + + /** + * A boolean representing if a delete operation is pending + * @type {BehaviorSubject<boolean>} + */ + public processingDelete$ = new BehaviorSubject<boolean>(false); + + /** + * Initialize instance variables + * + * @param {Injector} injector + * @param {Router} router + * @param {NgbModal} modalService + * @param {NotificationsService} notificationsService + * @param {TranslateService} translate + */ + constructor(protected injector: Injector, + protected router: Router, + protected modalService: NgbModal, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { + super(ResourceType.Workspaceitem, injector, router, notificationsService, translate); + } + + /** + * Delete the target workspaceitem object + */ + public confirmDiscard(content) { + this.modalService.open(content).result.then( + (result) => { + if (result === 'ok') { + this.processingDelete$.next(true); + this.objectDataService.delete(this.object) + .subscribe((response: boolean) => { + this.processingDelete$.next(false); + this.handleActionResponse(response); + }) + } + } + ); + } + + /** + * Init the target object + * + * @param {Workspaceitem} object + */ + initObjects(object: Workspaceitem) { + this.object = object; + } + +} diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index a81ee5a88274427e649ef1fed5bec9eb21441de0..5ba889892aa5ff435dba37cfde66f966b401a39e 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -1,6 +1,7 @@ <ds-object-list [config]="config" [sortConfig]="sortConfig" [objects]="objects" + [hasBorder]="hasBorder" [hideGear]="hideGear" (paginationChange)="onPaginationChange($event)" (pageChange)="onPageChange($event)" @@ -22,4 +23,11 @@ *ngIf="getViewMode()===viewModeEnum.Grid"> </ds-object-grid> +<ds-object-detail [config]="config" + [sortConfig]="sortConfig" + [objects]="objects" + [hideGear]="hideGear" + *ngIf="getViewMode()===viewModeEnum.Detail"> +</ds-object-detail> + diff --git a/src/app/shared/object-collection/object-collection.component.spec.ts b/src/app/shared/object-collection/object-collection.component.spec.ts index 66767ff78e54ed02d46cdbe22fe9cc711c6b62f3..aed2b2598d8034aabdc55d9496fd52f014450ec2 100644 --- a/src/app/shared/object-collection/object-collection.component.spec.ts +++ b/src/app/shared/object-collection/object-collection.component.spec.ts @@ -1,11 +1,13 @@ import { ObjectCollectionComponent } from './object-collection.component'; +import { SetViewMode } from '../view-mode'; +import { element } from 'protractor'; import { By } from '@angular/platform-browser'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Config } from '../../../config/config.interface'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { of as observableOf } from 'rxjs'; import { RouterStub } from '../testing/router-stub'; -import { ViewMode } from '../../core/shared/view-mode.model'; describe('ObjectCollectionComponent', () => { let fixture: ComponentFixture<ObjectCollectionComponent>; @@ -36,14 +38,14 @@ describe('ObjectCollectionComponent', () => { })); it('should only show the grid component when the viewmode is set to grid', () => { - objectCollectionComponent.currentMode = ViewMode.Grid; + objectCollectionComponent.currentMode = SetViewMode.Grid; expect(fixture.debugElement.query(By.css('ds-object-grid'))).toBeDefined(); expect(fixture.debugElement.query(By.css('ds-object-list'))).toBeNull(); }); it('should only show the list component when the viewmode is set to list', () => { - objectCollectionComponent.currentMode = ViewMode.List; + objectCollectionComponent.currentMode = SetViewMode.List; expect(fixture.debugElement.query(By.css('ds-object-list'))).toBeDefined(); expect(fixture.debugElement.query(By.css('ds-object-grid'))).toBeNull(); diff --git a/src/app/shared/object-collection/object-collection.component.ts b/src/app/shared/object-collection/object-collection.component.ts index 0018c55c7fc0215be32eac0344294d744d549c1b..ccc1de1f2fbdc8fb6bfbef471526e30dbba3e534 100644 --- a/src/app/shared/object-collection/object-collection.component.ts +++ b/src/app/shared/object-collection/object-collection.component.ts @@ -1,23 +1,25 @@ - -import {map} from 'rxjs/operators'; -import { Component, EventEmitter, +import { + ChangeDetectorRef, + Component, + EventEmitter, Input, + OnChanges, OnInit, - Output, SimpleChanges, OnChanges, ChangeDetectorRef } from '@angular/core'; + Output, + SimpleChanges +} from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { RemoteData } from '../../core/data/remote-data'; import { PageInfo } from '../../core/shared/page-info.model'; - import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; - import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; - import { ListableObject } from './shared/listable-object.model'; +import { SetViewMode } from '../view-mode'; import { hasValue, isNotEmpty } from '../empty.util'; -import { ViewMode } from '../../core/shared/view-mode.model'; @Component({ selector: 'ds-viewable-collection', @@ -29,6 +31,7 @@ export class ObjectCollectionComponent implements OnChanges, OnInit { @Input() objects: RemoteData<ListableObject[]>; @Input() config?: PaginationComponentOptions; @Input() sortConfig: SortOptions; + @Input() hasBorder = false; @Input() hideGear = false; pageInfo: Observable<PageInfo>; private sub; @@ -58,8 +61,8 @@ export class ObjectCollectionComponent implements OnChanges, OnInit { */ @Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>(); data: any = {}; - currentMode: ViewMode = ViewMode.List; - viewModeEnum = ViewMode; + currentMode: SetViewMode = SetViewMode.List; + viewModeEnum = SetViewMode; ngOnChanges(changes: SimpleChanges) { if (changes.objects && !changes.objects.isFirstChange()) { @@ -80,16 +83,20 @@ export class ObjectCollectionComponent implements OnChanges, OnInit { } /** + * @param cdRef + * ChangeDetectorRef service provided by Angular. * @param route * Route is a singleton service provided by Angular. * @param router * Router is a singleton service provided by Angular. */ - constructor(private cdRef: ChangeDetectorRef, private route: ActivatedRoute, - private router: Router) { + constructor( + private cdRef: ChangeDetectorRef, + private route: ActivatedRoute, + private router: Router) { } - getViewMode(): ViewMode { + getViewMode(): SetViewMode { this.route.queryParams.pipe(map((params) => { if (isNotEmpty(params.view) && hasValue(params.view)) { this.currentMode = params.view; diff --git a/src/app/shared/object-collection/shared/claimed-task-my-dspace-result.model.ts b/src/app/shared/object-collection/shared/claimed-task-my-dspace-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..051bfe6d9f9ef85049b47dde62fdb0bec5569789 --- /dev/null +++ b/src/app/shared/object-collection/shared/claimed-task-my-dspace-result.model.ts @@ -0,0 +1,11 @@ +import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; +import { SearchResult } from '../../../+search-page/search-result.model'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; +import { searchResultFor } from '../../../+search-page/search-service/search-result-element-decorator'; + +/** + * Represents a search result object of a ClaimedTask object + */ +@searchResultFor(ClaimedTask, MyDSpaceConfigurationValueType.Workflow) +export class ClaimedTaskMyDSpaceResult extends SearchResult<ClaimedTask> { +} diff --git a/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts b/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts index 9243d13b8ec40f38d224bc4631e701d7e7a8d38f..12da0f5b362d2630f34d8031fc2189556f043d86 100644 --- a/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts +++ b/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts @@ -1,10 +1,10 @@ +import { SetViewMode } from '../../view-mode'; import { renderElementsFor } from './dso-element-decorator'; import { Item } from '../../../core/shared/item.model'; -import { ViewMode } from '../../../core/shared/view-mode.model'; describe('ElementDecorator', () => { - const gridDecorator = renderElementsFor(Item, ViewMode.Grid); - const listDecorator = renderElementsFor(Item, ViewMode.List); + const gridDecorator = renderElementsFor(Item, SetViewMode.Grid); + const listDecorator = renderElementsFor(Item, SetViewMode.List); it('should have a decorator for both list and grid', () => { expect(listDecorator.length).not.toBeNull(); expect(gridDecorator.length).not.toBeNull(); diff --git a/src/app/shared/object-collection/shared/dso-element-decorator.ts b/src/app/shared/object-collection/shared/dso-element-decorator.ts index 98650fd25bb1699f555b15222a1ba6d6dab691f1..4e4d37579a2c022bbef6445d15ad8b51c0af6aa7 100644 --- a/src/app/shared/object-collection/shared/dso-element-decorator.ts +++ b/src/app/shared/object-collection/shared/dso-element-decorator.ts @@ -1,9 +1,9 @@ import { GenericConstructor } from '../../../core/shared/generic-constructor'; import { ListableObject } from './listable-object.model'; -import { ViewMode } from '../../../core/shared/view-mode.model'; +import { SetViewMode } from '../../view-mode'; const dsoElementMap = new Map(); -export function renderElementsFor(listable: GenericConstructor<ListableObject>, viewMode: ViewMode) { +export function renderElementsFor(listable: GenericConstructor<ListableObject>, viewMode: SetViewMode) { return function decorator(objectElement: any) { if (!objectElement) { return; @@ -15,6 +15,6 @@ export function renderElementsFor(listable: GenericConstructor<ListableObject>, }; } -export function rendersDSOType(listable: GenericConstructor<ListableObject>, viewMode: ViewMode) { +export function rendersDSOType(listable: GenericConstructor<ListableObject>, viewMode: SetViewMode) { return dsoElementMap.get(viewMode).get(listable); } diff --git a/src/app/shared/object-collection/shared/item-my-dspace-result.model.ts b/src/app/shared/object-collection/shared/item-my-dspace-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..92724a762f8b84b5be1529658265acd15dfb063a --- /dev/null +++ b/src/app/shared/object-collection/shared/item-my-dspace-result.model.ts @@ -0,0 +1,11 @@ +import { Item } from '../../../core/shared/item.model'; +import { SearchResult } from '../../../+search-page/search-result.model'; +import { searchResultFor } from '../../../+search-page/search-service/search-result-element-decorator'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; + +/** + * Represents a search result object of a Item object + */ +@searchResultFor(Item, MyDSpaceConfigurationValueType.Workspace) +export class ItemMyDSpaceResult extends SearchResult<Item> { +} diff --git a/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status-type.ts b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status-type.ts new file mode 100644 index 0000000000000000000000000000000000000000..48a0a6f4a39789cb24759594e945bb17d1204c61 --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status-type.ts @@ -0,0 +1,7 @@ +export enum MyDspaceItemStatusType { + WORKFLOW = 'mydspace.status.workflow', + VALIDATION = 'mydspace.status.validation', + WAITING_CONTROLLER = 'mydspace.status.waiting-for-controller', + WORKSPACE = 'mydspace.status.workspace', + ARCHIVED = 'mydspace.status.archived' +} diff --git a/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.html b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.html new file mode 100644 index 0000000000000000000000000000000000000000..848dd502a48eb0f9e13b9f33ed390f6ff54c666b --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.html @@ -0,0 +1,5 @@ +<div> + <span [className]="badgeClass"> + {{badgeContent | translate}} + </span> +</div> diff --git a/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.scss b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.spec.ts b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c23ab3603b0d8dfbe85a557155660a63141dcb5 --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.spec.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { RemoteData } from '../../../../core/data/remote-data'; + +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { MyDSpaceItemStatusComponent } from './my-dspace-item-status.component'; +import { MyDspaceItemStatusType } from './my-dspace-item-status-type'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; +import { By } from '@angular/platform-browser'; + +let component: MyDSpaceItemStatusComponent; +let fixture: ComponentFixture<MyDSpaceItemStatusComponent>; + +let mockResultObject: PoolTask; + +const rdSumbitter = new RemoteData(false, false, true, null, EPersonMock); +const workflowitem = Object.assign(new Workflowitem(), { submitter: observableOf(rdSumbitter) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockResultObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem) }); + +describe('MyDSpaceItemStatusComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [MyDSpaceItemStatusComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MyDSpaceItemStatusComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpaceItemStatusComponent); + component = fixture.componentInstance; + }); + + it('should display badge', () => { + const badge = fixture.debugElement.query(By.css('span')); + expect(badge).toBeDefined(); + }); + + it('should init badge content and class', () => { + component.status = MyDspaceItemStatusType.VALIDATION; + fixture.detectChanges(); + expect(component.badgeContent).toBe(MyDspaceItemStatusType.VALIDATION); + expect(component.badgeClass).toBe('text-light badge badge-warning'); + }); + + it('should init badge content and class', () => { + component.status = MyDspaceItemStatusType.WAITING_CONTROLLER; + fixture.detectChanges(); + expect(component.badgeContent).toBe(MyDspaceItemStatusType.WAITING_CONTROLLER); + expect(component.badgeClass).toBe('text-light badge badge-info'); + }); + + it('should init badge content and class', () => { + component.status = MyDspaceItemStatusType.WORKSPACE; + fixture.detectChanges(); + expect(component.badgeContent).toBe(MyDspaceItemStatusType.WORKSPACE); + expect(component.badgeClass).toBe('text-light badge badge-primary'); + }); + + it('should init badge content and class', () => { + component.status = MyDspaceItemStatusType.ARCHIVED; + fixture.detectChanges(); + expect(component.badgeContent).toBe(MyDspaceItemStatusType.ARCHIVED); + expect(component.badgeClass).toBe('text-light badge badge-success'); + }); + + it('should init badge content and class', () => { + component.status = MyDspaceItemStatusType.WORKFLOW; + fixture.detectChanges(); + expect(component.badgeContent).toBe(MyDspaceItemStatusType.WORKFLOW); + expect(component.badgeClass).toBe('text-light badge badge-info'); + }); +}); diff --git a/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.ts b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..917dd45accc587f5ee7d50c304b70dd639461a3a --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status.component.ts @@ -0,0 +1,54 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MyDspaceItemStatusType } from './my-dspace-item-status-type'; + +/** + * This component represents a badge with mydspace item status + */ +@Component({ + selector: 'ds-mydspace-item-status', + styleUrls: ['./my-dspace-item-status.component.scss'], + templateUrl: './my-dspace-item-status.component.html' +}) +export class MyDSpaceItemStatusComponent implements OnInit { + + /** + * This mydspace item status + */ + @Input() status: MyDspaceItemStatusType; + + /** + * This badge class + */ + public badgeClass: string; + + /** + * This badge content + */ + public badgeContent: string; + + /** + * Initialize badge content and class + */ + ngOnInit() { + this.badgeContent = this.status; + this.badgeClass = 'text-light badge '; + switch (this.status) { + case MyDspaceItemStatusType.VALIDATION: + this.badgeClass += 'badge-warning'; + break; + case MyDspaceItemStatusType.WAITING_CONTROLLER: + this.badgeClass += 'badge-info'; + break; + case MyDspaceItemStatusType.WORKSPACE: + this.badgeClass += 'badge-primary'; + break; + case MyDspaceItemStatusType.ARCHIVED: + this.badgeClass += 'badge-success'; + break; + case MyDspaceItemStatusType.WORKFLOW: + this.badgeClass += 'badge-info'; + break; + } + } + +} diff --git a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html new file mode 100644 index 0000000000000000000000000000000000000000..47674025ca89395fa0d3bfab2f18ad45fdbd1647 --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html @@ -0,0 +1,3 @@ +<div class="mt-2 mb-2"> + <span class="text-muted">{{'submission.workflow.tasks.generic.submitter' | translate}} : <span class="badge badge-light">{{(submitter$ | async)?.name}}</span></span> +</div> diff --git a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.scss b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.spec.ts b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..77460a3674eaa71cdc03c86a4111cdb64374b97a --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.spec.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; + +import { RemoteData } from '../../../../core/data/remote-data'; +import { ItemSubmitterComponent } from './item-submitter.component'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; +import { By } from '@angular/platform-browser'; + +let component: ItemSubmitterComponent; +let fixture: ComponentFixture<ItemSubmitterComponent>; + +const compIndex = 1; + +let mockResultObject: PoolTask; + +const rdSumbitter = new RemoteData(false, false, true, null, EPersonMock); +const workflowitem = Object.assign(new Workflowitem(), { submitter: observableOf(rdSumbitter) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockResultObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem) }); + +describe('ItemSubmitterComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ItemSubmitterComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemSubmitterComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemSubmitterComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.object = mockResultObject; + fixture.detectChanges(); + }); + + it('should init submitter properly', () => { + expect(component.submitter$).toBeObservable(cold('(b|)', { + b: EPersonMock + })); + }); + + it('should show a badge with submitter name', () => { + const badge = fixture.debugElement.query(By.css('.badge')); + + expect(badge).toBeDefined(); + expect(badge.nativeElement.innerHTML).toBe(EPersonMock.name); + }); +}); diff --git a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.ts b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..f752fa6f04d6955db49c9c18045bdd75bbff68b5 --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.ts @@ -0,0 +1,41 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { filter, find, flatMap, map } from 'rxjs/operators'; + +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotEmpty, isNotUndefined } from '../../../empty.util'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; + +/** + * This component represents a badge with submitter information. + */ +@Component({ + selector: 'ds-item-submitter', + styleUrls: ['./item-submitter.component.scss'], + templateUrl: './item-submitter.component.html' +}) +export class ItemSubmitterComponent implements OnInit { + + /** + * The target object + */ + @Input() object: any; + + /** + * The Eperson object + */ + submitter$: Observable<EPerson>; + + /** + * Initialize submitter object + */ + ngOnInit() { + this.submitter$ = (this.object.workflowitem as Observable<RemoteData<Workflowitem>>).pipe( + filter((rd: RemoteData<Workflowitem>) => (rd.hasSucceeded && isNotUndefined(rd.payload))), + flatMap((rd: RemoteData<Workflowitem>) => rd.payload.submitter as Observable<RemoteData<EPerson>>), + find((rd: RemoteData<EPerson>) => rd.hasSucceeded && isNotEmpty(rd.payload)), + map((rd: RemoteData<EPerson>) => rd.payload)); + } +} diff --git a/src/app/shared/object-collection/shared/pool-task-my-dspace-result.model.ts b/src/app/shared/object-collection/shared/pool-task-my-dspace-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bc2c4c4c2d72d9f03ac6f998064b7672ae36c52 --- /dev/null +++ b/src/app/shared/object-collection/shared/pool-task-my-dspace-result.model.ts @@ -0,0 +1,11 @@ +import { PoolTask } from '../../../core/tasks/models/pool-task-object.model'; +import { SearchResult } from '../../../+search-page/search-result.model'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; +import { searchResultFor } from '../../../+search-page/search-service/search-result-element-decorator'; + +/** + * Represents a search result object of a PoolTask object + */ +@searchResultFor(PoolTask, MyDSpaceConfigurationValueType.Workflow) +export class PoolTaskMyDSpaceResult extends SearchResult<PoolTask> { +} diff --git a/src/app/shared/object-collection/shared/workflowitem-my-dspace-result.model.ts b/src/app/shared/object-collection/shared/workflowitem-my-dspace-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..89f6f351adacfff6f588248eeb6bc508af60fd65 --- /dev/null +++ b/src/app/shared/object-collection/shared/workflowitem-my-dspace-result.model.ts @@ -0,0 +1,11 @@ +import { Workflowitem } from '../../../core/submission/models/workflowitem.model'; +import { SearchResult } from '../../../+search-page/search-result.model'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; +import { searchResultFor } from '../../../+search-page/search-service/search-result-element-decorator'; + +/** + * Represents a search result object of a Workflowitem object + */ +@searchResultFor(Workflowitem, MyDSpaceConfigurationValueType.Workspace) +export class WorkflowitemMyDSpaceResult extends SearchResult<Workflowitem> { +} diff --git a/src/app/shared/object-collection/shared/workspaceitem-my-dspace-result.model.ts b/src/app/shared/object-collection/shared/workspaceitem-my-dspace-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..20474c9ad3fa2ed8e085b80858740e280b1123c3 --- /dev/null +++ b/src/app/shared/object-collection/shared/workspaceitem-my-dspace-result.model.ts @@ -0,0 +1,11 @@ +import { Workspaceitem } from '../../../core/submission/models/workspaceitem.model'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; +import { searchResultFor } from '../../../+search-page/search-service/search-result-element-decorator'; +import { SearchResult } from '../../../+search-page/search-result.model'; + +/** + * Represents a search result object of a Workspaceitem object + */ +@searchResultFor(Workspaceitem, MyDSpaceConfigurationValueType.Workspace) +export class WorkspaceitemMyDSpaceResult extends SearchResult<Workspaceitem> { +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..d4ecaaa33296ef5d648788ac8a9646aabf827cc1 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.html @@ -0,0 +1,8 @@ +<ds-item-detail-preview *ngIf="workflowitem" + [item]="(workflowitem.item | async)?.payload" + [object]="object" + [showSubmitter]="showSubmitter" + [status]="status"> +</ds-item-detail-preview> + +<ds-claimed-task-actions *ngIf="workflowitem" [object]="dso"></ds-claimed-task-actions> diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3307721ff29e6cde777bb56d01f81a22eef0ca7 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.spec.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { ClaimedMyDSpaceResultDetailElementComponent } from './claimed-my-dspace-result-detail-element.component'; +import { ClaimedTaskMyDSpaceResult } from '../../../object-collection/shared/claimed-task-my-dspace-result.model'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; + +let component: ClaimedMyDSpaceResultDetailElementComponent; +let fixture: ComponentFixture<ClaimedMyDSpaceResultDetailElementComponent>; + +const compIndex = 1; + +const mockResultObject: ClaimedTaskMyDSpaceResult = new ClaimedTaskMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = new RemoteData(false, false, true, null, item); +const workflowitem = Object.assign(new Workflowitem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); + +describe('ClaimedMyDSpaceResultDetailElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ClaimedMyDSpaceResultDetailElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedMyDSpaceResultDetailElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ClaimedMyDSpaceResultDetailElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.workflowitem).toEqual(workflowitem); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.VALIDATION); + }); +}); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f7b0aab5509167498cfbe4449628833c1802789 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component.ts @@ -0,0 +1,68 @@ +import { Component, Inject } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { isNotUndefined } from '../../../empty.util'; +import { ListableObject } from '../../../object-collection/shared/listable-object.model'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { ClaimedTaskMyDSpaceResult } from '../../../object-collection/shared/claimed-task-my-dspace-result.model'; +import { MyDSpaceResultDetailElementComponent } from '../my-dspace-result-detail-element.component'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders claimed task object for the mydspace result in the detail view. + */ +@Component({ + selector: 'ds-claimed-my-dspace-result-detail-element', + styleUrls: ['../my-dspace-result-detail-element.component.scss'], + templateUrl: './claimed-my-dspace-result-detail-element.component.html' +}) + +@renderElementsFor(ClaimedTaskMyDSpaceResult, SetViewMode.Detail) +@renderElementsFor(ClaimedTask, SetViewMode.Detail) +export class ClaimedMyDSpaceResultDetailElementComponent extends MyDSpaceResultDetailElementComponent<ClaimedTaskMyDSpaceResult, ClaimedTask> { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.VALIDATION; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitem: Workflowitem; + + constructor(@Inject('objectElementProvider') public listable: ListableObject) { + super(listable); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initWorkflowItem(this.dso.workflowitem as Observable<RemoteData<Workflowitem>>); + } + + /** + * Retrieve workflowitem from result object + */ + initWorkflowItem(wfi$: Observable<RemoteData<Workflowitem>>) { + wfi$.pipe( + find((rd: RemoteData<Workflowitem>) => (rd.hasSucceeded && isNotUndefined(rd.payload))) + ).subscribe((rd: RemoteData<Workflowitem>) => { + this.workflowitem = rd.payload; + }); + } + +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.html new file mode 100644 index 0000000000000000000000000000000000000000..2b7e7f8f6e52b8c492d8bd3a76e21e093a170ace --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.html @@ -0,0 +1,10 @@ +<ds-metadata-field-wrapper [label]="label | translate"> + <ng-container *ngIf="item.hasMetadata(metadata)"> + <span *ngFor="let mdValue of allMetadataValues(metadata); let last=last;"> + {{mdValue}}<span *ngIf="!last" [innerHTML]="separator"></span> + </span> + </ng-container> + <ng-container *ngIf="!item.hasMetadata(metadata)"> + <span class="text-muted">{{(placeholder | translate)}}</span> + </ng-container> +</ds-metadata-field-wrapper> diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff5224a1a20b14d2856a5d6676ab316024f04f5b --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.spec.ts @@ -0,0 +1,98 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { ItemDetailPreviewFieldComponent } from './item-detail-preview-field.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { MockTranslateLoader } from '../../../../mocks/mock-translate-loader'; +import { By } from '@angular/platform-browser'; + +let component: ItemDetailPreviewFieldComponent; +let fixture: ComponentFixture<ItemDetailPreviewFieldComponent>; + +const mockItemWithAuthorAndDate: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } +}); + +describe('ItemDetailPreviewFieldComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [ItemDetailPreviewFieldComponent, TruncatePipe], + providers: [ + { provide: 'objectElementProvider', useValue: { mockItemWithAuthorAndDate } } + + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemDetailPreviewFieldComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemDetailPreviewFieldComponent); + component = fixture.componentInstance; + + })); + + beforeEach(() => { + component.object = { hitHighlights: {} } as any; + component.item = mockItemWithAuthorAndDate; + component.label = 'test label'; + component.metadata = 'dc.title'; + component.placeholder = 'No title'; + fixture.detectChanges(); + }); + + it('should display dc.title value', () => { + const span = fixture.debugElement.query(By.css('span')); + expect(span.nativeElement.innerHTML).toContain('This is just another title'); + }); + + it('should display placeholder when metadata has no value', () => { + component.metadata = 'dc.abstract'; + component.placeholder = 'No abstract'; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('.text-muted')); + expect(span.nativeElement.innerHTML).toContain('No abstract'); + }); +}); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..b53d0a6b2dd6d7078063511905de244bdb685189 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts @@ -0,0 +1,55 @@ +import { Component, Input } from '@angular/core'; + +import { Metadata } from '../../../../../core/shared/metadata.utils'; +import { MyDSpaceResult } from '../../../../../+my-dspace-page/my-dspace-result.model'; +import { Item } from '../../../../../core/shared/item.model'; + +/** + * This component show values for the given item metadata + */ +@Component({ + selector: 'ds-item-detail-preview-field', + templateUrl: './item-detail-preview-field.component.html' +}) +export class ItemDetailPreviewFieldComponent { + + /** + * The item to display + */ + @Input() item: Item; + + /** + * The mydspace result object + */ + @Input() object: MyDSpaceResult<any>; + + /** + * The metadata label + */ + @Input() label: string; + + /** + * The metadata to show + */ + @Input() metadata: string | string[]; + + /** + * The placeholder if there are no value to show + */ + @Input() placeholder: string; + + /** + * The value's separator + */ + @Input() separator: string; + + /** + * 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.item.metadata], keyOrKeys); + } +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html new file mode 100644 index 0000000000000000000000000000000000000000..04e128c49a5f97faa6c562768b1ecec6233cb2ed --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html @@ -0,0 +1,62 @@ +<div *ngIf="item" class="item-page" @fadeInOut> + <ng-container *ngIf="status"> + <ds-mydspace-item-status [status]="status"></ds-mydspace-item-status> + </ng-container> + <div *ngIf="item"> + <h2 class="item-page-title-field"> + <ds-metadata-values *ngIf="item.hasMetadata('dc.title')" [mdValues]="item?.allMetadata('dc.title')"></ds-metadata-values> + <span class="text-muted" *ngIf="!item.hasMetadata('dc.title')">{{('mydspace.results.no-title' | translate)}}</span> + </h2> + <div class="row mb-1"> + <div class="col-xs-12 col-md-4"> + <ds-metadata-field-wrapper> + <ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> + </ds-metadata-field-wrapper> + <ng-container *ngVar="(bitstreams$ | async) as bitstreams"> + <ds-metadata-field-wrapper [label]="('item.page.files' | translate)"> + <div *ngIf="bitstreams?.length > 0" class="file-section"> + <a *ngFor="let file of bitstreams; let last=last;" [href]="file?.content" target="_blank" [download]="file?.name"> + <span>{{file?.name}}</span> + <span>({{(file?.sizeBytes) |Â dsFileSize }})</span> + <span *ngIf="!last" innerHTML="{{separator}}"></span> + </a> + </div> + <ng-container *ngIf="bitstreams?.length === 0"> + <span class="text-muted">{{('mydspace.results.no-files' | translate)}}</span> + </ng-container> + </ds-metadata-field-wrapper> + </ng-container> + <ds-item-detail-preview-field [item]="item" + [object]="object" + [label]="('item.page.date' | translate)" + [metadata]="'dc.date.issued'" + [separator]="separator" + [placeholder]="('mydspace.results.no-date' | translate)"></ds-item-detail-preview-field> + <ds-item-detail-preview-field [item]="item" + [object]="object" + [label]="('item.page.author' | translate)" + [metadata]="['dc.contributor', 'dc.creator', 'dc.contributor.*']" + [separator]="separator" + [placeholder]="('mydspace.results.no-authors' | translate)"></ds-item-detail-preview-field> + </div> + <div class="col-xs-12 col-md-6"> + <ds-item-detail-preview-field [item]="item" + [object]="object" + [label]="('item.page.abstract' | translate)" + [metadata]="'dc.description.abstract'" + [separator]="separator" + [placeholder]="('mydspace.results.no-abstract' | translate)"></ds-item-detail-preview-field> + <ds-item-detail-preview-field [item]="item" + [object]="object" + [label]="('item.page.uri' | translate)" + [metadata]="'dc.identifier.uri'" + [separator]="separator" + [placeholder]="('mydspace.results.no-uri' | translate)"></ds-item-detail-preview-field> + <div> + <ng-content></ng-content> + </div> + </div> + </div> + </div> +</div> +<ds-item-submitter *ngIf="showSubmitter" [object]="object.indexableObject"></ds-item-submitter> diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.scss b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0af614a6cd91ffda72d3f7ce9d1362efcc2d754 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.spec.ts @@ -0,0 +1,83 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { TruncatePipe } from '../../../utils/truncate.pipe'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemDetailPreviewComponent } from './item-detail-preview.component'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; +import { ItemDetailPreviewFieldComponent } from './item-detail-preview-field/item-detail-preview-field.component'; +import { FileSizePipe } from '../../../utils/file-size-pipe'; +import { VarDirective } from '../../../utils/var.directive'; + +let component: ItemDetailPreviewComponent; +let fixture: ComponentFixture<ItemDetailPreviewComponent>; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf([]), + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } +}); + +describe('ItemDetailPreviewComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [ItemDetailPreviewComponent, ItemDetailPreviewFieldComponent, TruncatePipe, FileSizePipe, VarDirective], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemDetailPreviewComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemDetailPreviewComponent); + component = fixture.componentInstance; + component.object = { hitHighlights: {} } as any; + component.item = mockItem; + component.separator = ', '; + spyOn(component.item, 'getFiles').and.returnValue(mockItem.bitstreams); + fixture.detectChanges(); + + })); + + it('should init thumbnail and bitstreams on init', () => { + expect(component.thumbnail$).toBeDefined(); + expect(component.bitstreams$).toBeDefined(); + }); +}); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..d26bfc45896602c9262043622175efb533a662d7 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.ts @@ -0,0 +1,65 @@ +import { Component, Input } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { fadeInOut } from '../../../animations/fade'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { MyDSpaceResult } from '../../../../+my-dspace-page/my-dspace-result.model'; + +/** + * This component show metadata for the given item object in the detail view. + */ +@Component({ + selector: 'ds-item-detail-preview', + styleUrls: ['./item-detail-preview.component.scss'], + templateUrl: './item-detail-preview.component.html', + animations: [fadeInOut] +}) +export class ItemDetailPreviewComponent { + + /** + * The item to display + */ + @Input() item: Item; + + /** + * The mydspace result object + */ + @Input() object: MyDSpaceResult<any>; + + /** + * Represent item's status + */ + @Input() status: MyDspaceItemStatusType; + + /** + * A boolean representing if to show submitter information + */ + @Input() showSubmitter = false; + + /** + * The item's thumbnail + */ + public bitstreams$: Observable<Bitstream[]>; + + /** + * The value's separator + */ + public separator = ', '; + + /** + * The item's thumbnail + */ + public thumbnail$: Observable<Bitstream>; + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.thumbnail$ = this.item.getThumbnail(); + this.bitstreams$ = this.item.getFiles(); + } + +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..2b687960ab11cf2cd37303ba9cba657cc2824ed1 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.html @@ -0,0 +1,8 @@ +<ds-item-detail-preview [item]="dso" + [object]="object" + [status]="status"> +</ds-item-detail-preview> + +<ds-item-actions [object]="dso"></ds-item-actions> + + diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.scss b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..1d0786105ca4dc61051610513a3fdbe21424a6d5 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables'; diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c99f44cbae02f55d2028d6191ede7cadca14671d --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.spec.ts @@ -0,0 +1,78 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { ItemMyDSpaceResultDetailElementComponent } from './item-my-dspace-result-detail-element.component'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { ItemMyDSpaceResult } from '../../../object-collection/shared/item-my-dspace-result.model'; + +let component: ItemMyDSpaceResultDetailElementComponent; +let fixture: ComponentFixture<ItemMyDSpaceResultDetailElementComponent>; + +const compIndex = 1; + +const mockResultObject: ItemMyDSpaceResult = new ItemMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +mockResultObject.indexableObject = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); + +describe('ItemMyDSpaceResultDetailElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ItemMyDSpaceResultDetailElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemMyDSpaceResultDetailElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemMyDSpaceResultDetailElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.ARCHIVED); + }); +}); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..26f63deb83d4f5786d58b0a80bcc1662d509eec2 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component.ts @@ -0,0 +1,28 @@ +import { Component } from '@angular/core'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemMyDSpaceResult } from '../../../object-collection/shared/item-my-dspace-result.model'; +import { MyDSpaceResultDetailElementComponent } from '../my-dspace-result-detail-element.component'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders item object for the mydspace result in the detail view. + */ +@Component({ + selector: 'ds-workspaceitem-my-dspace-result-detail-element', + styleUrls: ['../my-dspace-result-detail-element.component.scss', './item-my-dspace-result-detail-element.component.scss'], + templateUrl: './item-my-dspace-result-detail-element.component.html' +}) + +@renderElementsFor(ItemMyDSpaceResult, SetViewMode.Detail) +export class ItemMyDSpaceResultDetailElementComponent extends MyDSpaceResultDetailElementComponent<ItemMyDSpaceResult, Item> { + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.ARCHIVED; + +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/my-dspace-result-detail-element.component.scss b/src/app/shared/object-detail/my-dspace-result-detail-element/my-dspace-result-detail-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..9a442f84e172313856eb7e18639ff4806d317646 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/my-dspace-result-detail-element.component.scss @@ -0,0 +1 @@ +@import '../../object-grid/search-result-grid-element/search-result-grid-element.component'; diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/my-dspace-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/my-dspace-result-detail-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..47a44d31324bfbea9eea36ec0ada738b44eea2b5 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/my-dspace-result-detail-element.component.ts @@ -0,0 +1,49 @@ +import { Component, Inject } from '@angular/core'; + +import { MyDSpaceResult } from '../../../+my-dspace-page/my-dspace-result.model'; +import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { ListableObject } from '../../object-collection/shared/listable-object.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; + +@Component({ + selector: 'ds-my-dspace-result-detail-element', + template: `` +}) +export class MyDSpaceResultDetailElementComponent<T extends MyDSpaceResult<K>, K extends DSpaceObject> extends AbstractListableElementComponent<T> { + + /** + * The result element object + */ + dso: K; + + /** + * Initialize instance variables + * + * @param {ListableObject} detailable + */ + public constructor(@Inject('objectElementProvider') public detailable: ListableObject) { + super(detailable); + this.dso = this.object.indexableObject; + } + + /** + * 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); + } + + /** + * 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); + } +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..96ef68e3f87fa1bfbf37e679100663e0c0b2fda7 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-element.component.html @@ -0,0 +1,7 @@ +<ds-item-detail-preview *ngIf="workflowitem" + [item]="(workflowitem.item | async)?.payload" + [object]="object" + [showSubmitter]="showSubmitter" + [status]="status"></ds-item-detail-preview> + +<ds-pool-task-actions *ngIf="workflowitem" [object]="dso"></ds-pool-task-actions> diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0eea01aa1cb3146601389fb736b7ca0bd74f57ac --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-element.component.spec.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { PoolMyDSpaceResultDetailElementComponent } from './pool-my-dspace-result-detail-lement.component'; +import { PoolTaskMyDSpaceResult } from '../../../object-collection/shared/pool-task-my-dspace-result.model'; +import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; + +let component: PoolMyDSpaceResultDetailElementComponent; +let fixture: ComponentFixture<PoolMyDSpaceResultDetailElementComponent>; + +const compIndex = 1; + +const mockResultObject: PoolTaskMyDSpaceResult = new PoolTaskMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = new RemoteData(false, false, true, null, item); +const workflowitem = Object.assign(new Workflowitem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockResultObject.indexableObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem) }); + +describe('PoolMyDSpaceResultDetailElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [PoolMyDSpaceResultDetailElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PoolMyDSpaceResultDetailElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PoolMyDSpaceResultDetailElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.workflowitem).toEqual(workflowitem); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.WAITING_CONTROLLER); + }); +}); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-lement.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-lement.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef7bcadebfd61d5fc935c6b54d3ff1094e317ddd --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-lement.component.ts @@ -0,0 +1,68 @@ +import { Component, Inject } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotUndefined } from '../../../empty.util'; +import { ListableObject } from '../../../object-collection/shared/listable-object.model'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; +import { PoolTaskMyDSpaceResult } from '../../../object-collection/shared/pool-task-my-dspace-result.model'; +import { MyDSpaceResultDetailElementComponent } from '../my-dspace-result-detail-element.component'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders pool task object for the mydspace result in the detail view. + */ +@Component({ + selector: 'ds-pool-my-dspace-result-detail-element', + styleUrls: ['../my-dspace-result-detail-element.component.scss'], + templateUrl: './pool-my-dspace-result-detail-element.component.html', +}) + +@renderElementsFor(PoolTaskMyDSpaceResult, SetViewMode.Detail) +@renderElementsFor(PoolTask, SetViewMode.Detail) +export class PoolMyDSpaceResultDetailElementComponent extends MyDSpaceResultDetailElementComponent<PoolTaskMyDSpaceResult, PoolTask> { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.WAITING_CONTROLLER; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitem: Workflowitem; + + constructor(@Inject('objectElementProvider') public listable: ListableObject) { + super(listable); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initWorkflowItem(this.dso.workflowitem as Observable<RemoteData<Workflowitem>>); + } + + /** + * Retrieve workflowitem from result object + */ + initWorkflowItem(wfi$: Observable<RemoteData<Workflowitem>>) { + wfi$.pipe( + find((rd: RemoteData<Workflowitem>) => (rd.hasSucceeded && isNotUndefined(rd.payload))) + ).subscribe((rd: RemoteData<Workflowitem>) => { + this.workflowitem = rd.payload; + }); + } + +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..7ff1a9bf728e4903abb622aa8965707270619854 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.html @@ -0,0 +1,6 @@ +<ds-item-detail-preview [item]="item" + [object]="object" + [status]="status"></ds-item-detail-preview> + +<ds-workflowitem-actions [object]="dso"></ds-workflowitem-actions> + diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8bdbf9fd6b81e02df26013b9660287b55b38967 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.spec.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { WorkflowitemMyDSpaceResultDetailElementComponent } from './workflowitem-my-dspace-result-detail-element.component'; +import { WorkflowitemMyDSpaceResult } from '../../../object-collection/shared/workflowitem-my-dspace-result.model'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; + +let component: WorkflowitemMyDSpaceResultDetailElementComponent; +let fixture: ComponentFixture<WorkflowitemMyDSpaceResultDetailElementComponent>; + +const compIndex = 1; + +const mockResultObject: WorkflowitemMyDSpaceResult = new WorkflowitemMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rd = new RemoteData(false, false, true, null, item); +mockResultObject.indexableObject = Object.assign(new Workflowitem(), { item: observableOf(rd) }); + +describe('WorkflowitemMyDSpaceResultDetailElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [WorkflowitemMyDSpaceResultDetailElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(WorkflowitemMyDSpaceResultDetailElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(WorkflowitemMyDSpaceResultDetailElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.item).toEqual(item); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.WORKFLOW); + }); +}); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c4b0c668f59538f48949ccf14d17a4876a251d6 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component.ts @@ -0,0 +1,62 @@ +import { Component, Inject } from '@angular/core'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { Item } from '../../../../core/shared/item.model'; +import { ListableObject } from '../../../object-collection/shared/listable-object.model'; +import { WorkflowitemMyDSpaceResult } from '../../../object-collection/shared/workflowitem-my-dspace-result.model'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { MyDSpaceResultDetailElementComponent } from '../my-dspace-result-detail-element.component'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { find } from 'rxjs/operators'; +import { isNotUndefined } from '../../../empty.util'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders workflowitem object for the mydspace result in the detail view. + */ +@Component({ + selector: 'ds-workflowitem-my-dspace-result-detail-element', + styleUrls: ['../my-dspace-result-detail-element.component.scss'], + templateUrl: './workflowitem-my-dspace-result-detail-element.component.html', +}) + +@renderElementsFor(WorkflowitemMyDSpaceResult, SetViewMode.Detail) +@renderElementsFor(Workflowitem, SetViewMode.Detail) +export class WorkflowitemMyDSpaceResultDetailElementComponent extends MyDSpaceResultDetailElementComponent<WorkflowitemMyDSpaceResult, Workflowitem> { + + /** + * The item object that belonging to the result object + */ + public item: Item; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.WORKFLOW; + + constructor(@Inject('objectElementProvider') public listable: ListableObject) { + super(listable); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initItem(this.dso.item as Observable<RemoteData<Item>>); + } + + /** + * Retrieve item from result object + */ + initItem(item$: Observable<RemoteData<Item>>) { + item$.pipe( + find((rd: RemoteData<Item>) => rd.hasSucceeded && isNotUndefined(rd.payload)) + ).subscribe((rd: RemoteData<Item>) => { + this.item = rd.payload; + }); + } + +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..8d4eee6ba92685fb80641fcaf1a28ed13fa3a5f3 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.html @@ -0,0 +1,5 @@ +<ds-item-detail-preview [item]="item" + [object]="object" + [status]="status"></ds-item-detail-preview> + +<ds-workspaceitem-actions [object]="dso"></ds-workspaceitem-actions> diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.scss b/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..af67bf53ea1bc2c4cda908b3b61980d4a68b9816 --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.spec.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { WorkspaceitemMyDSpaceResultDetailElementComponent } from './workspaceitem-my-dspace-result-detail-element.component'; +import { WorkspaceitemMyDSpaceResult } from '../../../object-collection/shared/workspaceitem-my-dspace-result.model'; +import { Workspaceitem } from '../../../../core/submission/models/workspaceitem.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; + +let component: WorkspaceitemMyDSpaceResultDetailElementComponent; +let fixture: ComponentFixture<WorkspaceitemMyDSpaceResultDetailElementComponent>; + +const compIndex = 1; + +const mockResultObject: WorkspaceitemMyDSpaceResult = new WorkspaceitemMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rd = new RemoteData(false, false, true, null, item); +mockResultObject.indexableObject = Object.assign(new Workspaceitem(), { item: observableOf(rd) }); + +describe('WorkspaceitemMyDSpaceResultDetailElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [WorkspaceitemMyDSpaceResultDetailElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(WorkspaceitemMyDSpaceResultDetailElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(WorkspaceitemMyDSpaceResultDetailElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.item).toEqual(item); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.WORKSPACE); + }); +}); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..390db7ec284147ca19d0649e8a425061e8312d1f --- /dev/null +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component.ts @@ -0,0 +1,62 @@ +import { Component, Inject } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { Workspaceitem } from '../../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemMyDSpaceResult } from '../../../object-collection/shared/workspaceitem-my-dspace-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotUndefined } from '../../../empty.util'; +import { ListableObject } from '../../../object-collection/shared/listable-object.model'; +import { MyDSpaceResultDetailElementComponent } from '../my-dspace-result-detail-element.component'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders workspaceitem object for the mydspace result in the detail view. + */ +@Component({ + selector: 'ds-workspaceitem-my-dspace-result-detail-element', + styleUrls: ['../my-dspace-result-detail-element.component.scss', './workspaceitem-my-dspace-result-detail-element.component.scss'], + templateUrl: './workspaceitem-my-dspace-result-detail-element.component.html', +}) + +@renderElementsFor(WorkspaceitemMyDSpaceResult, SetViewMode.Detail) +@renderElementsFor(Workspaceitem, SetViewMode.Detail) +export class WorkspaceitemMyDSpaceResultDetailElementComponent extends MyDSpaceResultDetailElementComponent<WorkspaceitemMyDSpaceResult, Workspaceitem> { + + /** + * The item object that belonging to the result object + */ + public item: Item; + + /** + * Represent item's status + */ + status = MyDspaceItemStatusType.WORKSPACE; + + constructor(@Inject('objectElementProvider') public listable: ListableObject) { + super(listable); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initItem(this.dso.item as Observable<RemoteData<Item>>); + } + + /** + * Retrieve item from result object + */ + initItem(item$: Observable<RemoteData<Item>>) { + item$.pipe( + find((rd: RemoteData<Item>) => rd.hasSucceeded && isNotUndefined(rd.payload)) + ).subscribe((rd: RemoteData<Item>) => { + this.item = rd.payload; + }); + } +} diff --git a/src/app/shared/object-detail/object-detail.component.html b/src/app/shared/object-detail/object-detail.component.html new file mode 100644 index 0000000000000000000000000000000000000000..a57a23027e3c948d2ce40c3d21894bdd2c275abe --- /dev/null +++ b/src/app/shared/object-detail/object-detail.component.html @@ -0,0 +1,22 @@ +<ds-pagination + [paginationOptions]="config" + [pageInfoState]="objects?.payload" + [collectionSize]="objects?.payload?.totalElements" + [sortOptions]="sortConfig" + [hideGear]="hideGear" + [hidePaginationDetail]="hidePaginationDetail" + [hidePagerWhenSinglePage]="hidePagerWhenSinglePage" + (pageChange)="onPageChange($event)" + (pageSizeChange)="onPageSizeChange($event)" + (sortDirectionChange)="onSortDirectionChange($event)" + (sortFieldChange)="onSortFieldChange($event)" + (paginationChange)="onPaginationChange($event)"> + <div class="row mt-2" *ngIf="objects?.hasSucceeded" @fadeIn> + <div class="col" + *ngFor="let object of objects?.payload?.page"> + <ds-wrapper-detail-element [object]="object"></ds-wrapper-detail-element> + </div> + </div> + <ds-error *ngIf="objects.hasFailed | async" message="{{'error.objects' | translate}}"></ds-error> + <ds-loading *ngIf="objects.isLoading | async" message="{{'loading.objects' | translate}}"></ds-loading> +</ds-pagination> diff --git a/src/app/shared/object-detail/object-detail.component.scss b/src/app/shared/object-detail/object-detail.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..9a077e3e08ecb6e8fa8362cf04e4f8f8bc6fbec8 --- /dev/null +++ b/src/app/shared/object-detail/object-detail.component.scss @@ -0,0 +1,9 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; + +ds-wrapper-detail-element ::ng-deep { + div.thumbnail > img { + height: $card-thumbnail-height; + width: 100%; + } +} diff --git a/src/app/shared/object-detail/object-detail.component.spec.ts b/src/app/shared/object-detail/object-detail.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b81f1019f2bec0cb2410f6249ed9b2360ed5554 --- /dev/null +++ b/src/app/shared/object-detail/object-detail.component.spec.ts @@ -0,0 +1,181 @@ +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ObjectDetailComponent } from './object-detail.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../mocks/mock-translate-loader'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('ObjectDetailComponent', () => { + let comp: ObjectDetailComponent; + let fixture: ComponentFixture<ObjectDetailComponent>; + const testEvent = {test: 'test'}; + + const testObjects = [ + { one: 1 }, + { two: 2 }, + { three: 3 }, + { four: 4 }, + { five: 5 }, + { six: 6 }, + { seven: 7 }, + { eight: 8 }, + { nine: 9 }, + { ten: 10 } + ]; + const pageInfo = Object.assign(new PageInfo(), {elementsPerPage: 1, totalElements: 10, totalPages: 10, currentPage: 1}) + const mockRD = new RemoteData(false, false, true, null, new PaginatedList(pageInfo, testObjects)); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ObjectDetailComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ObjectDetailComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ObjectDetailComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + comp.objects = mockRD; + fixture.detectChanges(); + }); + + describe('when the pageChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(comp, 'onPageChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('pageChange', testEvent); + }); + + it('should call onPageChange on the component', () => { + expect(comp.onPageChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the pageSizeChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(comp, 'onPageSizeChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('pageSizeChange', testEvent); + }); + + it('should call onPageSizeChange on the component', () => { + expect(comp.onPageSizeChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the sortDirectionChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(comp, 'onSortDirectionChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('sortDirectionChange', testEvent); + }); + + it('should call onSortDirectionChange on the component', () => { + expect(comp.onSortDirectionChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the sortFieldChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(comp, 'onSortFieldChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('sortFieldChange', testEvent); + }); + + it('should call onSortFieldChange on the component', () => { + expect(comp.onSortFieldChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when the paginationChange output on the pagination is triggered', () => { + beforeEach(() => { + spyOn(comp, 'onPaginationChange'); + const paginationEl = fixture.debugElement.query(By.css('ds-pagination')); + paginationEl.triggerEventHandler('paginationChange', testEvent); + }); + + it('should call onPaginationChange on the component', () => { + expect(comp.onPaginationChange).toHaveBeenCalledWith(testEvent); + }); + }); + + describe('when onPageChange is triggered with an event', () => { + beforeEach(() => { + spyOn(comp.pageChange, 'emit'); + comp.onPageChange(testEvent); + }); + + it('should emit the value from the pageChange EventEmitter', fakeAsync(() => { + tick(1); + expect(comp.pageChange.emit).toHaveBeenCalled(); + expect(comp.pageChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onPageSizeChange is triggered with an event', () => { + beforeEach(() => { + spyOn(comp.pageSizeChange, 'emit'); + comp.onPageSizeChange(testEvent); + }); + + it('should emit the value from the pageSizeChange EventEmitter', fakeAsync(() => { + tick(1); + expect(comp.pageSizeChange.emit).toHaveBeenCalled(); + expect(comp.pageSizeChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onSortDirectionChange is triggered with an event', () => { + beforeEach(() => { + spyOn(comp.sortDirectionChange, 'emit'); + comp.onSortDirectionChange(testEvent); + }); + + it('should emit the value from the sortDirectionChange EventEmitter', fakeAsync(() => { + tick(1); + expect(comp.sortDirectionChange.emit).toHaveBeenCalled(); + expect(comp.sortDirectionChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onSortFieldChange is triggered with an event', () => { + beforeEach(() => { + spyOn(comp.sortFieldChange, 'emit'); + comp.onSortFieldChange(testEvent); + }); + + it('should emit the value from the sortFieldChange EventEmitter', fakeAsync(() => { + tick(1); + expect(comp.sortFieldChange.emit).toHaveBeenCalled(); + expect(comp.sortFieldChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); + + describe('when onPaginationChange is triggered with an event', () => { + beforeEach(() => { + spyOn(comp.paginationChange, 'emit'); + comp.onPaginationChange(testEvent); + }); + + it('should emit the value from the paginationChange EventEmitter', fakeAsync(() => { + tick(1); + expect(comp.paginationChange.emit).toHaveBeenCalled(); + expect(comp.paginationChange.emit).toHaveBeenCalledWith(testEvent); + })); + }); +}); diff --git a/src/app/shared/object-detail/object-detail.component.ts b/src/app/shared/object-detail/object-detail.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..3187a2cd1b8e56eeddfcd3f089f83367c6ed5a98 --- /dev/null +++ b/src/app/shared/object-detail/object-detail.component.ts @@ -0,0 +1,154 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewEncapsulation +} from '@angular/core'; + +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { PaginatedList } from '../../core/data/paginated-list'; + +import { RemoteData } from '../../core/data/remote-data'; +import { fadeIn } from '../animations/fade'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; + +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; + +/** + * This component renders a paginated set of results in the detail view. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.Emulated, + selector: 'ds-object-detail', + styleUrls: [ './object-detail.component.scss' ], + templateUrl: './object-detail.component.html', + animations: [fadeIn] +}) +export class ObjectDetailComponent { + + /** + * Pagination options object + */ + @Input() config: PaginationComponentOptions; + + /** + * Sort options object + */ + @Input() sortConfig: SortOptions; + + /** + * A boolean representing if to hide gear pagination icon + */ + @Input() hideGear = true; + + /** + * A boolean representing if to hide pagination when there is only a page + */ + @Input() hidePagerWhenSinglePage = true; + + /** + * The list of objects to paginate + */ + private _objects: RemoteData<PaginatedList<ListableObject>>; + + /** + * Setter for _objects property + * @param objects + */ + @Input() set objects(objects: RemoteData<PaginatedList<ListableObject>>) { + this._objects = objects; + } + + /** + * Getter for _objects property + */ + get objects() { + return this._objects; + } + + /** + * Option for hiding the pagination detail + */ + public hidePaginationDetail = true; + + /** + * An event fired when the page is changed. + * Event's payload equals to the newly selected page. + */ + @Output() change: EventEmitter<{ + pagination: PaginationComponentOptions, + sort: SortOptions + }> = new EventEmitter<{ + pagination: PaginationComponentOptions, + sort: SortOptions + }>(); + + /** + * An event fired when the page is changed. + * Event's payload equals to the newly selected page. + */ + @Output() pageChange: EventEmitter<number> = new EventEmitter<number>(); + + /** + * An event fired when the page wsize is changed. + * Event's payload equals to the newly selected page size. + */ + @Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>(); + + /** + * An event fired when the sort direction is changed. + * Event's payload equals to the newly selected sort direction. + */ + @Output() sortDirectionChange: EventEmitter<SortDirection> = new EventEmitter<SortDirection>(); + + /** + * An event fired when the pagination is changed. + * Event's payload equals to the newly selected sort direction. + */ + @Output() paginationChange: EventEmitter<SortDirection> = new EventEmitter<any>(); + + /** + * An event fired when the sort field is changed. + * Event's payload equals to the newly selected sort field. + */ + @Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>(); + + /** + * Emit pageChange event + */ + onPageChange(event) { + this.pageChange.emit(event); + } + + /** + * Emit pageSizeChange event + */ + onPageSizeChange(event) { + this.pageSizeChange.emit(event); + } + + /** + * Emit sortDirectionChange event + */ + onSortDirectionChange(event) { + this.sortDirectionChange.emit(event); + } + + /** + * Emit sortFieldChange event + */ + onSortFieldChange(event) { + this.sortFieldChange.emit(event); + } + + /** + * Emit paginationChange event + */ + onPaginationChange(event) { + this.paginationChange.emit(event); + } + +} diff --git a/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.html b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..00a8ed2dc8bd1cbaab3bf1168204c6a531961bb2 --- /dev/null +++ b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.html @@ -0,0 +1 @@ +<ng-container *ngComponentOutlet="getDetailElement(); injector: objectInjector;"></ng-container> diff --git a/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.scss b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..51a7fc6a558e9c28a56325c7f82ff3204956ab0e --- /dev/null +++ b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.scss @@ -0,0 +1,2 @@ +@import '../../../../styles/variables'; + diff --git a/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.spec.ts b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e54ae58398d8ef28ebfd860a35b71e3f673a1e4f --- /dev/null +++ b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { of as observableOf } from 'rxjs'; + +import { RouterStub } from '../../testing/router-stub'; +import { WrapperDetailElementComponent } from './wrapper-detail-element.component'; + +let wrapperDetailElementComponent: WrapperDetailElementComponent; +let fixture: ComponentFixture<WrapperDetailElementComponent>; +const queryParam = 'test query'; +const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; +const activatedRouteStub = { + queryParams: observableOf({ + query: queryParam, + scope: scopeParam + }) +}; + +describe('WrapperDetailElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ WrapperDetailElementComponent ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Router, useClass: RouterStub }, + { provide: 'objectElementProvider', useFactory: (WrapperDetailElementComponent)} + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).compileComponents(); // compile template and css + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(WrapperDetailElementComponent); + wrapperDetailElementComponent = fixture.componentInstance; + })); + + it('should show the wrapper element containing the detail object',() => { + expect(fixture.debugElement.query(By.css('ds-workspaceitem-my-dspace-result-detail-element'))).toBeDefined(); + }) +}); diff --git a/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.ts b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..92b30f9ce72dcbd7dd7bc54619e0ec00a2a7bd4a --- /dev/null +++ b/src/app/shared/object-detail/wrapper-detail-element/wrapper-detail-element.component.ts @@ -0,0 +1,55 @@ +import { Component, Injector, Input, OnInit } from '@angular/core'; + +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { GenericConstructor } from '../../../core/shared/generic-constructor'; +import { rendersDSOType } from '../../object-collection/shared/dso-element-decorator'; +import { ListableObject } from '../../object-collection/shared/listable-object.model'; +import { SetViewMode } from '../../view-mode'; + +/** + * This component renders a wrapper for an object in the detail view. + */ +@Component({ + selector: 'ds-wrapper-detail-element', + styleUrls: ['./wrapper-detail-element.component.scss'], + templateUrl: './wrapper-detail-element.component.html' +}) +export class WrapperDetailElementComponent implements OnInit { + + /** + * The listable object. + */ + @Input() object: ListableObject; + + /** + * The instance of the injector. + */ + objectInjector: Injector; + + /** + * Initialize instance variables + * + * @param {Injector} injector + */ + constructor(private injector: Injector) { + } + + /** + * Initialize injector + */ + ngOnInit(): void { + this.objectInjector = Injector.create({ + providers: [{ provide: 'objectElementProvider', useFactory: () => (this.object), deps:[] }], + parent: this.injector + }); + + } + + /** + * Return class name for the object to inject + */ + getDetailElement(): string { + const f: GenericConstructor<ListableObject> = this.object.constructor as GenericConstructor<ListableObject>; + return rendersDSOType(f, SetViewMode.Detail); + } +} diff --git a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts index a690037619c52c889d9271be468d9da2aed7dfe2..af6027aa1a1f6acb717cecd0f0d57873f85fda35 100644 --- a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts +++ b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts @@ -2,8 +2,8 @@ import { Component, Inject } from '@angular/core'; import { Collection } from '../../../core/shared/collection.model'; import { renderElementsFor} from '../../object-collection/shared/dso-element-decorator'; +import { SetViewMode } from '../../view-mode'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; -import { ViewMode } from '../../../core/shared/view-mode.model'; @Component({ selector: 'ds-collection-grid-element', @@ -11,5 +11,5 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; templateUrl: './collection-grid-element.component.html' }) -@renderElementsFor(Collection, ViewMode.Grid) +@renderElementsFor(Collection, SetViewMode.Grid) export class CollectionGridElementComponent extends AbstractListableElementComponent<Collection> {} diff --git a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts index 4df9ab9555e5a1d8f0e0202862348f2808034e84..4f69e3e2c7d90fad3766b5c8095ca03da40a98a8 100644 --- a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts +++ b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts @@ -3,7 +3,7 @@ import { Component, Input, Inject } from '@angular/core'; import { Community } from '../../../core/shared/community.model'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { renderElementsFor} from '../../object-collection/shared/dso-element-decorator'; -import { ViewMode } from '../../../core/shared/view-mode.model'; +import { SetViewMode } from '../../view-mode'; @Component({ selector: 'ds-community-grid-element', @@ -11,5 +11,5 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; templateUrl: './community-grid-element.component.html' }) -@renderElementsFor(Community, ViewMode.Grid) +@renderElementsFor(Community, SetViewMode.Grid) export class CommunityGridElementComponent extends AbstractListableElementComponent<Community> {} diff --git a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts index 7a679fef25ed20f0c660b4263a49e90983ea32c0..a4137b3c26a169c2901f95155c05fdd4799edb2b 100644 --- a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts +++ b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts @@ -3,7 +3,7 @@ import { Component, Input, Inject } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { renderElementsFor} from '../../object-collection/shared/dso-element-decorator'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; -import { ViewMode } from '../../../core/shared/view-mode.model'; +import { SetViewMode } from '../../view-mode'; @Component({ selector: 'ds-item-grid-element', @@ -11,5 +11,5 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; templateUrl: './item-grid-element.component.html' }) -@renderElementsFor(Item, ViewMode.Grid) +@renderElementsFor(Item, SetViewMode.Grid) export class ItemGridElementComponent extends AbstractListableElementComponent<Item> {} 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 e8f8b1330e330ad5d79ee1af3e2b3bf60f6dc9a2..07f3960d556d07013b4ae00efcb19ab872dcfd3e 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 @@ -17,7 +17,7 @@ const truncatableServiceStub: any = { const mockCollectionWithAbstract: CollectionSearchResult = new CollectionSearchResult(); mockCollectionWithAbstract.hitHighlights = {}; -mockCollectionWithAbstract.dspaceObject = Object.assign(new Collection(), { +mockCollectionWithAbstract.indexableObject = Object.assign(new Collection(), { metadata: { 'dc.description.abstract': [ { @@ -30,7 +30,7 @@ mockCollectionWithAbstract.dspaceObject = Object.assign(new Collection(), { const mockCollectionWithoutAbstract: CollectionSearchResult = new CollectionSearchResult(); mockCollectionWithoutAbstract.hitHighlights = {}; -mockCollectionWithoutAbstract.dspaceObject = Object.assign(new Collection(), { +mockCollectionWithoutAbstract.indexableObject = Object.assign(new Collection(), { metadata: { 'dc.title': [ { @@ -63,7 +63,7 @@ describe('CollectionSearchResultGridElementComponent', () => { describe('When the collection has an abstract', () => { beforeEach(() => { - collectionSearchResultGridElementComponent.dso = mockCollectionWithAbstract.dspaceObject; + collectionSearchResultGridElementComponent.dso = mockCollectionWithAbstract.indexableObject; fixture.detectChanges(); }); @@ -75,7 +75,7 @@ describe('CollectionSearchResultGridElementComponent', () => { describe('When the collection has no abstract', () => { beforeEach(() => { - collectionSearchResultGridElementComponent.dso = mockCollectionWithoutAbstract.dspaceObject; + collectionSearchResultGridElementComponent.dso = mockCollectionWithoutAbstract.indexableObject; fixture.detectChanges(); }); diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts index c1f27bee4f11cb02f2c3e7f5c735273f03fd9c36..61cc5dca1ce5a4ef0db6732bc9681d72276fb7b1 100644 --- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts @@ -3,8 +3,8 @@ import { Component } from '@angular/core'; import { renderElementsFor} from '../../../object-collection/shared/dso-element-decorator'; import { SearchResultGridElementComponent } from '../search-result-grid-element.component'; import { Collection } from '../../../../core/shared/collection.model'; +import { SetViewMode } from '../../../view-mode'; import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; @Component({ selector: 'ds-collection-search-result-grid-element', @@ -12,5 +12,5 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; templateUrl: 'collection-search-result-grid-element.component.html' }) -@renderElementsFor(CollectionSearchResult, ViewMode.Grid) +@renderElementsFor(CollectionSearchResult, SetViewMode.Grid) export class CollectionSearchResultGridElementComponent extends SearchResultGridElementComponent<CollectionSearchResult, Collection> {} 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 e111e624c5927d008b11a1d38258280fc214ce7a..567b2e1d0e045d9aa5b3b3d68e2b171b8bc088e8 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 @@ -17,7 +17,7 @@ const truncatableServiceStub: any = { const mockCommunityWithAbstract: CommunitySearchResult = new CommunitySearchResult(); mockCommunityWithAbstract.hitHighlights = {}; -mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { +mockCommunityWithAbstract.indexableObject = Object.assign(new Community(), { metadata: { 'dc.description.abstract': [ { @@ -30,7 +30,7 @@ mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { const mockCommunityWithoutAbstract: CommunitySearchResult = new CommunitySearchResult(); mockCommunityWithoutAbstract.hitHighlights = {}; -mockCommunityWithoutAbstract.dspaceObject = Object.assign(new Community(), { +mockCommunityWithoutAbstract.indexableObject = Object.assign(new Community(), { metadata: { 'dc.title': [ { @@ -63,7 +63,7 @@ describe('CommunitySearchResultGridElementComponent', () => { describe('When the community has an abstract', () => { beforeEach(() => { - communitySearchResultGridElementComponent.dso = mockCommunityWithAbstract.dspaceObject; + communitySearchResultGridElementComponent.dso = mockCommunityWithAbstract.indexableObject; fixture.detectChanges(); }); @@ -75,7 +75,7 @@ describe('CommunitySearchResultGridElementComponent', () => { describe('When the community has no abstract', () => { beforeEach(() => { - communitySearchResultGridElementComponent.dso = mockCommunityWithoutAbstract.dspaceObject; + communitySearchResultGridElementComponent.dso = mockCommunityWithoutAbstract.indexableObject; fixture.detectChanges(); }); diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts index 8fac11a6a4ca18c35fd32eee4d40f373c1a1b9ba..d445e27ed6570c39916a7f1f8075e75eff7d54e7 100644 --- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts @@ -2,8 +2,8 @@ import { Component } from '@angular/core'; import { Community } from '../../../../core/shared/community.model'; import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; import { SearchResultGridElementComponent } from '../search-result-grid-element.component'; +import { SetViewMode } from '../../../view-mode'; import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; @Component({ selector: 'ds-community-search-result-grid-element', @@ -11,7 +11,7 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; templateUrl: 'community-search-result-grid-element.component.html' }) -@renderElementsFor(CommunitySearchResult, ViewMode.Grid) +@renderElementsFor(CommunitySearchResult, SetViewMode.Grid) export class CommunitySearchResultGridElementComponent extends SearchResultGridElementComponent<CommunitySearchResult, Community> { } 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 0103fa5c49b929ccea2de8620b223dfba1570a1f..655fd268a75d786d09a751e2841fb5286ab47c67 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 @@ -18,7 +18,7 @@ const truncatableServiceStub: any = { const mockItemWithAuthorAndDate: ItemSearchResult = new ItemSearchResult(); mockItemWithAuthorAndDate.hitHighlights = {}; -mockItemWithAuthorAndDate.dspaceObject = Object.assign(new Item(), { +mockItemWithAuthorAndDate.indexableObject = Object.assign(new Item(), { bitstreams: observableOf({}), metadata: { 'dc.contributor.author': [ @@ -38,7 +38,7 @@ mockItemWithAuthorAndDate.dspaceObject = Object.assign(new Item(), { const mockItemWithoutAuthorAndDate: ItemSearchResult = new ItemSearchResult(); mockItemWithoutAuthorAndDate.hitHighlights = {}; -mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { +mockItemWithoutAuthorAndDate.indexableObject = Object.assign(new Item(), { bitstreams: observableOf({}), metadata: { 'dc.title': [ @@ -78,7 +78,7 @@ describe('ItemSearchResultGridElementComponent', () => { describe('When the item has an author', () => { beforeEach(() => { - itemSearchResultGridElementComponent.dso = mockItemWithAuthorAndDate.dspaceObject; + itemSearchResultGridElementComponent.dso = mockItemWithAuthorAndDate.indexableObject; fixture.detectChanges(); }); @@ -90,7 +90,7 @@ describe('ItemSearchResultGridElementComponent', () => { describe('When the item has no author', () => { beforeEach(() => { - itemSearchResultGridElementComponent.dso = mockItemWithoutAuthorAndDate.dspaceObject; + itemSearchResultGridElementComponent.dso = mockItemWithoutAuthorAndDate.indexableObject; fixture.detectChanges(); }); @@ -102,7 +102,7 @@ describe('ItemSearchResultGridElementComponent', () => { describe('When the item has an issuedate', () => { beforeEach(() => { - itemSearchResultGridElementComponent.dso = mockItemWithAuthorAndDate.dspaceObject; + itemSearchResultGridElementComponent.dso = mockItemWithAuthorAndDate.indexableObject; fixture.detectChanges(); }); @@ -114,7 +114,7 @@ describe('ItemSearchResultGridElementComponent', () => { describe('When the item has no issuedate', () => { beforeEach(() => { - itemSearchResultGridElementComponent.dso = mockItemWithoutAuthorAndDate.dspaceObject; + itemSearchResultGridElementComponent.dso = mockItemWithoutAuthorAndDate.indexableObject; fixture.detectChanges(); }); diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts index 232c1617797af5dfd784e0885243c1173612d7d3..30c36b3af9f38b04cb35be09ff3399ede565fa4f 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts @@ -4,8 +4,8 @@ import { renderElementsFor } from '../../../object-collection/shared/dso-element import { SearchResultGridElementComponent } from '../search-result-grid-element.component'; import { Item } from '../../../../core/shared/item.model'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { SetViewMode } from '../../../view-mode'; import { focusShadow } from '../../../../shared/animations/focus'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; @Component({ selector: 'ds-item-search-result-grid-element', @@ -14,5 +14,5 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; animations: [focusShadow], }) -@renderElementsFor(ItemSearchResult, ViewMode.Grid) +@renderElementsFor(ItemSearchResult, SetViewMode.Grid) export class ItemSearchResultGridElementComponent extends SearchResultGridElementComponent<ItemSearchResult, Item> {} 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 844d0bc165170ff5ad43110974c944e4207aa5d6..0961dc96ee3c1fd39f0dd8e78c3390c5d1f16480 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 @@ -18,7 +18,7 @@ export class SearchResultGridElementComponent<T extends SearchResult<K>, K exten public constructor(@Inject('objectElementProvider') public listableObject: ListableObject, private truncatableService: TruncatableService) { super(listableObject); - this.dso = this.object.dspaceObject; + this.dso = this.object.indexableObject; } /** diff --git a/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts b/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts index e16aac6799c565773d73b8a74ed7cf02a6e3417a..84f9357b2d1ba921d80f7338113e2014bccbb5bd 100644 --- a/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts +++ b/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts @@ -1,8 +1,8 @@ import { Component, Injector, Input, OnInit } from '@angular/core'; +import { SetViewMode } from '../../view-mode'; import { GenericConstructor } from '../../../core/shared/generic-constructor'; import { rendersDSOType } from '../../object-collection/shared/dso-element-decorator'; import { ListableObject } from '../../object-collection/shared/listable-object.model'; -import { ViewMode } from '../../../core/shared/view-mode.model'; @Component({ selector: 'ds-wrapper-grid-element', @@ -26,6 +26,6 @@ export class WrapperGridElementComponent implements OnInit { getGridElement(): string { const f: GenericConstructor<ListableObject> = this.object.constructor as GenericConstructor<ListableObject>; - return rendersDSOType(f, ViewMode.Grid); + return rendersDSOType(f, SetViewMode.Grid); } } diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.ts b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.ts index 44f6a8d0517400359b050635527c11c26cef9074..a32cfb333e2c39a54c35c192b152a88b151ef088 100644 --- a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.ts +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.ts @@ -4,6 +4,7 @@ import { AbstractListableElementComponent } from '../../object-collection/shared import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator'; import { BrowseEntry } from '../../../core/shared/browse-entry.model'; import { ViewMode } from '../../../core/shared/view-mode.model'; +import { SetViewMode } from '../../view-mode'; @Component({ selector: 'ds-browse-entry-list-element', @@ -14,5 +15,5 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; /** * This component is automatically used to create a list view for BrowseEntry objects when used in ObjectCollectionComponent */ -@renderElementsFor(BrowseEntry, ViewMode.List) +@renderElementsFor(BrowseEntry, SetViewMode.List) export class BrowseEntryListElementComponent extends AbstractListableElementComponent<BrowseEntry> {} diff --git a/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts b/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts index b4e9de16b79dab4cb97d728c88f9fe58e95be696..3fe64f5bb9329bcc133adae90bbb20391f8305fd 100644 --- a/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts +++ b/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts @@ -2,8 +2,8 @@ import { Component, Inject } from '@angular/core'; import { Collection } from '../../../core/shared/collection.model'; import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator'; +import { SetViewMode } from '../../view-mode'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; -import { ViewMode } from '../../../core/shared/view-mode.model'; @Component({ selector: 'ds-collection-list-element', @@ -11,5 +11,5 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; templateUrl: './collection-list-element.component.html' }) -@renderElementsFor(Collection, ViewMode.List) +@renderElementsFor(Collection, SetViewMode.List) export class CollectionListElementComponent extends AbstractListableElementComponent<Collection> {} diff --git a/src/app/shared/object-list/community-list-element/community-list-element.component.ts b/src/app/shared/object-list/community-list-element/community-list-element.component.ts index 8f6870db7ed8400c65af22c0d3aa2a739e0c55dd..5e254cb1ac4d3742f14f63ae3fe50706353ac4c0 100644 --- a/src/app/shared/object-list/community-list-element/community-list-element.component.ts +++ b/src/app/shared/object-list/community-list-element/community-list-element.component.ts @@ -1,9 +1,9 @@ -import { Component } from '@angular/core'; +import { Component, Input, Inject } from '@angular/core'; import { Community } from '../../../core/shared/community.model'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator'; -import { ViewMode } from '../../../core/shared/view-mode.model'; +import { SetViewMode } from '../../view-mode'; @Component({ selector: 'ds-community-list-element', @@ -11,5 +11,5 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; templateUrl: './community-list-element.component.html' }) -@renderElementsFor(Community, ViewMode.List) +@renderElementsFor(Community, SetViewMode.List) export class CommunityListElementComponent extends AbstractListableElementComponent<Community> {} 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 8179b776297a600609fdda0c36ef13335be34783..d433c7acf24ce47b1cc4c820e700795566529541 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,24 +1 @@ -<ds-truncatable [id]="object.id"> - <a [routerLink]="['/items/' + object.id]" class="lead"> - {{object.firstMetadataValue("dc.title")}} - </a> - <div> - <ds-truncatable-part [id]="object.id" [minLines]="1"> - <span class="text-muted"> - <span *ngIf="object.hasMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'])" - class="item-list-authors"> - <span *ngFor="let author of object.allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']); let last=last;">{{author}} - <span *ngIf="!last">; </span> - </span> - </span> - (<span *ngIf="object.hasMetadata('dc.publisher')" class="item-list-publisher">{{object.firstMetadataValue("dc.publisher")}}, </span><span - *ngIf="object.hasMetadata('dc.date.issued')" class="item-list-date">{{object.firstMetadataValue("dc.date.issued")}}</span>) - </span> - </ds-truncatable-part> - <ds-truncatable-part [id]="object.id" [minLines]="3"> - <div *ngIf="object.hasMetadata('dc.description.abstract')" class="item-list-abstract"> - {{object.firstMetadataValue("dc.description.abstract")}} - </div> - </ds-truncatable-part> - </div> -</ds-truncatable> +<ds-item-type-switcher [object]="object" [viewMode]="viewMode"></ds-item-type-switcher> 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 392d81bee440b95c7ee04b25ad1230177dae8a9b..11fdae7e6dd3957e891012767fec1cf02a312050 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 @@ -1,114 +1,46 @@ -import { ItemListElementComponent } from './item-list-element.component'; 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 { ItemListElementComponent } from './item-list-element.component'; import { Item } from '../../../core/shared/item.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { By } from '@angular/platform-browser'; +import { createRelationshipsObservable } from '../../../+item-page/simple/item-types/shared/item.component.spec'; import { of as observableOf } from 'rxjs'; -let itemListElementComponent: ItemListElementComponent; -let fixture: ComponentFixture<ItemListElementComponent>; - -const mockItemWithAuthorAndDate: Item = Object.assign(new Item(), { - bitstreams: observableOf({}), - 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: { - 'dc.title': [ - { - language: 'en_US', - value: 'This is just another title' - } - ], - 'dc.type': [ - { - language: null, - value: 'Article' - } - ] - } +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() }); describe('ItemListElementComponent', () => { + let comp: ItemListElementComponent; + let fixture: ComponentFixture<ItemListElementComponent>; + beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ ItemListElementComponent , TruncatePipe], + imports: [], + declarations: [ItemListElementComponent], providers: [ - { provide: 'objectElementProvider', useValue: {mockItemWithAuthorAndDate}} + { provide: 'objectElementProvider', useValue: mockItem } ], - - schemas: [ NO_ERRORS_SCHEMA ] + schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ItemListElementComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default } + set: {changeDetection: ChangeDetectionStrategy.Default} }).compileComponents(); })); beforeEach(async(() => { fixture = TestBed.createComponent(ItemListElementComponent); - itemListElementComponent = fixture.componentInstance; - + comp = fixture.componentInstance; + fixture.detectChanges(); })); - describe('When the item has an author', () => { - beforeEach(() => { - itemListElementComponent.object = mockItemWithAuthorAndDate; - fixture.detectChanges(); - }); - - it('should show the author paragraph', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); - expect(itemAuthorField).not.toBeNull(); - }); + it('should call an item-type-switcher component and pass the item', () => { + const itemTypeSwitcher = fixture.debugElement.query(By.css('ds-item-type-switcher')).componentInstance; + expect(itemTypeSwitcher.object).toBe(mockItem); }); - describe('When the item has no author', () => { - beforeEach(() => { - itemListElementComponent.object = mockItemWithoutAuthorAndDate; - fixture.detectChanges(); - }); - - it('should not show the author paragraph', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); - expect(itemAuthorField).toBeNull(); - }); - }); - - describe('When the item has an issuedate', () => { - beforeEach(() => { - itemListElementComponent.object = mockItemWithAuthorAndDate; - fixture.detectChanges(); - }); - - it('should show the issuedate span', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-date')); - expect(itemAuthorField).not.toBeNull(); - }); - }); - - describe('When the item has no issuedate', () => { - beforeEach(() => { - itemListElementComponent.object = mockItemWithoutAuthorAndDate; - fixture.detectChanges(); - }); - - it('should not show the issuedate span', () => { - const dateField = fixture.debugElement.query(By.css('span.item-list-date')); - expect(dateField).toBeNull(); - }); - }); }); diff --git a/src/app/shared/object-list/item-list-element/item-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-list-element.component.ts index bc77b513fbc7fb92d5bfb43116a3a2fef652bb1f..67a6256d438f045488c6dffcb45284619e296665 100644 --- a/src/app/shared/object-list/item-list-element/item-list-element.component.ts +++ b/src/app/shared/object-list/item-list-element/item-list-element.component.ts @@ -1,9 +1,10 @@ import { Component } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; -import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator'; -import { ViewMode } from '../../../core/shared/view-mode.model'; +import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { SetViewMode } from '../../view-mode'; +import { ItemViewMode } from '../../items/item-type-decorator'; @Component({ selector: 'ds-item-list-element', @@ -11,5 +12,11 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; templateUrl: './item-list-element.component.html' }) -@renderElementsFor(Item, ViewMode.List) -export class ItemListElementComponent extends AbstractListableElementComponent<Item> {} +/** + * The component used to list items depending on type + * Uses item-type-switcher to determine which components to use for displaying the list + */ +@renderElementsFor(Item, SetViewMode.List) +export class ItemListElementComponent extends AbstractListableElementComponent<Item> { + viewMode = ItemViewMode.Element; +} diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..65a10ec1b7e328e52d52aa03bc2702c26bf8a6e8 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.html @@ -0,0 +1,21 @@ +<ds-truncatable [id]="item.id"> + <a + [routerLink]="['/items/' + item.id]" class="lead" + [innerHTML]="firstMetadataValue('dc.title')"></a> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="1"> + <span *ngIf="item.allMetadata(['journalvolume.identifier.volume']).length > 0" + class="item-list-journal-issues"> + <span *ngFor="let value of allMetadataValues(['journalvolume.identifier.volume']); let last=last;"> + <span [innerHTML]="value"><span [innerHTML]="value"></span></span> + </span> + <span *ngIf="item.allMetadata(['journalissue.identifier.number']).length > 0" + class="item-list-journal-issue-numbers"> + <span *ngFor="let value of allMetadataValues(['journalissue.identifier.number']); let last=last;"> + <span> - </span><span [innerHTML]="value"><span [innerHTML]="value"></span></span> + </span> + </span> + </span> + </ds-truncatable-part> + </span> +</ds-truncatable> diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.scss b/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..05de6c814bce9d83e3b18cab6a24960a7126f196 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.spec.ts @@ -0,0 +1,117 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; +import { JournalIssueListElementComponent } from './journal-issue-list-element.component'; +import { of as observableOf } from 'rxjs'; + +let journalIssueListElementComponent: JournalIssueListElementComponent; +let fixture: ComponentFixture<JournalIssueListElementComponent>; + +const mockItemWithMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'journalvolume.identifier.volume': [ + { + language: 'en_US', + value: '1234' + } + ], + 'journalissue.identifier.number': [ + { + language: 'en_US', + value: '5678' + } + ] + } +}); +const mockItemWithoutMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalIssueListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ JournalIssueListElementComponent , TruncatePipe], + providers: [ + { provide: ITEM, useValue: mockItemWithMetadata}, + { provide: TruncatableService, useValue: {} } + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(JournalIssueListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(JournalIssueListElementComponent); + journalIssueListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a journal identifier', () => { + beforeEach(() => { + journalIssueListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the journal issues span', () => { + const journalIdentifierField = fixture.debugElement.query(By.css('span.item-list-journal-issues')); + expect(journalIdentifierField).not.toBeNull(); + }); + }); + + describe('When the item has no journal identifier', () => { + beforeEach(() => { + journalIssueListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the journal issues span', () => { + const journalIdentifierField = fixture.debugElement.query(By.css('span.item-list-journal-issues')); + expect(journalIdentifierField).toBeNull(); + }); + }); + + describe('When the item has a journal number', () => { + beforeEach(() => { + journalIssueListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the journal issue numbers span', () => { + const journalNumberField = fixture.debugElement.query(By.css('span.item-list-journal-issue-numbers')); + expect(journalNumberField).not.toBeNull(); + }); + }); + + describe('When the item has no journal number', () => { + beforeEach(() => { + journalIssueListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the journal issue numbers span', () => { + const journalNumberField = fixture.debugElement.query(By.css('span.item-list-journal-issue-numbers')); + expect(journalNumberField).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..92a8dd99309b50e631cd0d933dcb6e5dd35259d8 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; + +@rendersItemType('JournalIssue', ItemViewMode.Element) +@Component({ + selector: 'ds-journal-issue-list-element', + styleUrls: ['./journal-issue-list-element.component.scss'], + templateUrl: './journal-issue-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Journal Issue + */ +export class JournalIssueListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..7d7f0cf731f6b5c2bf1b5b084f3ff6490a5c7773 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.html @@ -0,0 +1,21 @@ +<ds-truncatable [id]="item.id"> + <a + [routerLink]="['/items/' + item.id]" class="lead" + [innerHTML]="firstMetadataValue('dc.title')"></a> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="1"> + <span *ngIf="item.allMetadata(['journal.title']).length > 0" + class="item-list-journal-volumes"> + <span *ngFor="let value of allMetadataValues(['journal.title']); let last=last;"> + <span [innerHTML]="value"><span [innerHTML]="value"></span></span> + </span> + </span> + <span *ngIf="item.allMetadata(['journalvolume.identifier.volume']).length > 0" + class="item-list-journal-volume-identifiers"> + <span *ngFor="let value of allMetadataValues(['journalvolume.identifier.volume']); let last=last;"> + <span> (</span><span [innerHTML]="value"><span [innerHTML]="value"></span></span><span>)</span> + </span> + </span> + </ds-truncatable-part> + </span> +</ds-truncatable> diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.scss b/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cdfb0d732fa3e004e11a5dbed9508f796ecb542 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.spec.ts @@ -0,0 +1,117 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; +import { JournalVolumeListElementComponent } from './journal-volume-list-element.component'; +import { of as observableOf } from 'rxjs'; + +let journalVolumeListElementComponent: JournalVolumeListElementComponent; +let fixture: ComponentFixture<JournalVolumeListElementComponent>; + +const mockItemWithMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'journal.title': [ + { + language: 'en_US', + value: 'This is just another journal title' + } + ], + 'journalvolume.identifier.volume': [ + { + language: 'en_US', + value: '1234' + } + ] + } +}); +const mockItemWithoutMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalVolumeListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ JournalVolumeListElementComponent , TruncatePipe], + providers: [ + { provide: ITEM, useValue: mockItemWithMetadata}, + { provide: TruncatableService, useValue: {} } + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(JournalVolumeListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(JournalVolumeListElementComponent); + journalVolumeListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a journal title', () => { + beforeEach(() => { + journalVolumeListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the journal title span', () => { + const journalTitleField = fixture.debugElement.query(By.css('span.item-list-journal-volumes')); + expect(journalTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no journal title', () => { + beforeEach(() => { + journalVolumeListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the journal title span', () => { + const journalTitleField = fixture.debugElement.query(By.css('span.item-list-journal-volumes')); + expect(journalTitleField).toBeNull(); + }); + }); + + describe('When the item has a journal identifier', () => { + beforeEach(() => { + journalVolumeListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the journal identifiers span', () => { + const journalIdentifierField = fixture.debugElement.query(By.css('span.item-list-journal-volume-identifiers')); + expect(journalIdentifierField).not.toBeNull(); + }); + }); + + describe('When the item has no journal identifier', () => { + beforeEach(() => { + journalVolumeListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the journal identifiers span', () => { + const journalIdentifierField = fixture.debugElement.query(By.css('span.item-list-journal-volume-identifiers')); + expect(journalIdentifierField).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..01acf3ca344a602b76660711ab12ad28460e5664 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; + +@rendersItemType('JournalVolume', ItemViewMode.Element) +@Component({ + selector: 'ds-journal-volume-list-element', + styleUrls: ['./journal-volume-list-element.component.scss'], + templateUrl: './journal-volume-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Journal Volume + */ +export class JournalVolumeListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..32c8074503da60b966766a6631175c86d4c09f3e --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.html @@ -0,0 +1,15 @@ +<ds-truncatable [id]="item.id"> + <a + [routerLink]="['/items/' + item.id]" class="lead" + [innerHTML]="firstMetadataValue('dc.title')"></a> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="1"> + <span *ngIf="item.allMetadata(['journal.identifier.issn']).length > 0" + class="item-list-journals"> + <span *ngFor="let value of allMetadataValues(['journal.identifier.issn']); let last=last;"> + <span [innerHTML]="value"><span [innerHTML]="value"></span></span> + </span> + </span> + </ds-truncatable-part> + </span> +</ds-truncatable> diff --git a/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.scss b/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc7ef06fa044a0c993531df2a4e9d1689abe67bd --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.spec.ts @@ -0,0 +1,87 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; +import { JournalListElementComponent } from './journal-list-element.component'; +import { of as observableOf } from 'rxjs'; + +let journalListElementComponent: JournalListElementComponent; +let fixture: ComponentFixture<JournalListElementComponent>; + +const mockItemWithMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'journal.identifier.issn': [ + { + language: 'en_US', + value: '1234' + } + ] + } +}); +const mockItemWithoutMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ JournalListElementComponent , TruncatePipe], + providers: [ + { provide: ITEM, useValue: mockItemWithMetadata}, + { provide: TruncatableService, useValue: {} } + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(JournalListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(JournalListElementComponent); + journalListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has an issn', () => { + beforeEach(() => { + journalListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the journals span', () => { + const issnField = fixture.debugElement.query(By.css('span.item-list-journals')); + expect(issnField).not.toBeNull(); + }); + }); + + describe('When the item has no issn', () => { + beforeEach(() => { + journalListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the journals span', () => { + const issnField = fixture.debugElement.query(By.css('span.item-list-journals')); + expect(issnField).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..a40891b4526958d84c021b2dc2484cc44b1f20ad --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/journal/journal-list-element.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; + +@rendersItemType('Journal', ItemViewMode.Element) +@Component({ + selector: 'ds-journal-list-element', + styleUrls: ['./journal-list-element.component.scss'], + templateUrl: './journal-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Journal + */ +export class JournalListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..a809c0f65585a5c57d168ffd13dd34d8a1251fa3 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.html @@ -0,0 +1,16 @@ +<ds-truncatable [id]="item.id"> + <a + [routerLink]="['/items/' + item.id]" class="lead" + [innerHTML]="firstMetadataValue('orgunit.identifier.name')"></a> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="3"> + <span *ngIf="item.allMetadata(['orgunit.identifier.description']).length > 0" + class="item-list-orgunit-description"> + <ds-truncatable-part [id]="item.id" [minLines]="3"><span + [innerHTML]="firstMetadataValue('orgunit.identifier.description')"></span> + </ds-truncatable-part> + </span> + </ds-truncatable-part> + </span> +</ds-truncatable> + diff --git a/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.scss b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..5ab410dcb0f75817ae9ea534160253c7039e8ae0 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.scss @@ -0,0 +1 @@ +@import '../../../../../../styles/variables'; diff --git a/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e74c389e9a0a29365b30f2533702e57450bd653 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.spec.ts @@ -0,0 +1,87 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; +import { OrgUnitListElementComponent } from './orgunit-list-element.component'; +import { of as observableOf } from 'rxjs'; + +let orgUnitListElementComponent: OrgUnitListElementComponent; +let fixture: ComponentFixture<OrgUnitListElementComponent>; + +const mockItemWithMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'orgunit.identifier.description': [ + { + language: 'en_US', + value: 'A description about the OrgUnit' + } + ] + } +}); +const mockItemWithoutMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('OrgUnitListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ OrgUnitListElementComponent , TruncatePipe], + providers: [ + { provide: ITEM, useValue: mockItemWithMetadata}, + { provide: TruncatableService, useValue: {} } + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(OrgUnitListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(OrgUnitListElementComponent); + orgUnitListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has an orgunit description', () => { + beforeEach(() => { + orgUnitListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the description span', () => { + const orgunitDescriptionField = fixture.debugElement.query(By.css('span.item-list-orgunit-description')); + expect(orgunitDescriptionField).not.toBeNull(); + }); + }); + + describe('When the item has no orgunit description', () => { + beforeEach(() => { + orgUnitListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the description span', () => { + const orgunitDescriptionField = fixture.debugElement.query(By.css('span.item-list-orgunit-description')); + expect(orgunitDescriptionField).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..f047aac586d1c590f660ad7e1594e153c8975bda --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-list-element.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; + +@rendersItemType('OrgUnit', ItemViewMode.Element) +@Component({ + selector: 'ds-orgunit-list-element', + styleUrls: ['./orgunit-list-element.component.scss'], + templateUrl: './orgunit-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Organisation Unit + */ +export class OrgUnitListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-metadata-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-metadata-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..463770c0ae2516fb73415b996502b914620fb1eb --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-metadata-list-element.component.html @@ -0,0 +1,13 @@ +<ng-template #descTemplate> + <span class="text-muted"> + <span *ngIf="item.allMetadata(['orgunit.identifier.description']).length > 0" + class="item-list-job-title"> + <span [innerHTML]="firstMetadataValue(['orgunit.identifier.description'])"></span> + </span> + </span> +</ng-template> +<ds-truncatable [id]="item.id"> + <a [routerLink]="['/items/' + item.id]" + [innerHTML]="firstMetadataValue('orgunit.identifier.name')" + [tooltip]="descTemplate"></a> +</ds-truncatable> diff --git a/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-metadata-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-metadata-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..42c6c6f6a290122f32c86df5d497112d0b32c251 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/orgunit/orgunit-metadata-list-element.component.ts @@ -0,0 +1,15 @@ +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { Component } from '@angular/core'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; +import { MetadataRepresentationType } from '../../../../../core/shared/metadata-representation/metadata-representation.model'; + +@rendersItemType('OrgUnit', ItemViewMode.Element, MetadataRepresentationType.Item) +@Component({ + selector: 'ds-orgunit-metadata-list-element', + templateUrl: './orgunit-metadata-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type OrgUnit + */ +export class OrgUnitMetadataListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..52b69453cea78ea5974423b1a4c8200061186533 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.html @@ -0,0 +1,15 @@ +<ds-truncatable [id]="item.id"> + <a + [routerLink]="['/items/' + item.id]" class="lead" + [innerHTML]="firstMetadataValue('dc.contributor.author')"></a> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="1"> + <span *ngIf="item.allMetadata(['person.identifier.jobtitle']).length > 0" + class="item-list-job-title"> + <span *ngFor="let value of allMetadataValues(['person.identifier.jobtitle']); let last=last;"> + <span [innerHTML]="value"><span [innerHTML]="value"></span></span> + </span> + </span> + </ds-truncatable-part> + </span> +</ds-truncatable> diff --git a/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.scss b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..5ab410dcb0f75817ae9ea534160253c7039e8ae0 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.scss @@ -0,0 +1 @@ +@import '../../../../../../styles/variables'; diff --git a/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..67dc4e92ac551357acb6d6ecd7faa4ff346a5346 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.spec.ts @@ -0,0 +1,87 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; +import { PersonListElementComponent } from './person-list-element.component'; +import { of as observableOf } from 'rxjs'; + +let personListElementComponent: PersonListElementComponent; +let fixture: ComponentFixture<PersonListElementComponent>; + +const mockItemWithMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'person.identifier.jobtitle': [ + { + language: 'en_US', + value: 'Developer' + } + ] + } +}); +const mockItemWithoutMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('PersonListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ PersonListElementComponent , TruncatePipe], + providers: [ + { provide: ITEM, useValue: mockItemWithMetadata}, + { provide: TruncatableService, useValue: {} } + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(PersonListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PersonListElementComponent); + personListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a job title', () => { + beforeEach(() => { + personListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the job title span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-job-title')); + expect(jobTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no job title', () => { + beforeEach(() => { + personListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the job title span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-job-title')); + expect(jobTitleField).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3834a8aab96043d15498eef8036ea5be081e121 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/person/person-list-element.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; + +@rendersItemType('Person', ItemViewMode.Element) +@Component({ + selector: 'ds-person-list-element', + styleUrls: ['./person-list-element.component.scss'], + templateUrl: './person-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Person + */ +export class PersonListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/person/person-metadata-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/person/person-metadata-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..3dfe17debc53e65a4dd438747223d47f549e0e34 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/person/person-metadata-list-element.component.html @@ -0,0 +1,15 @@ +<ng-template #descTemplate> + <span class="text-muted"> + <span *ngIf="item.allMetadata(['person.identifier.jobtitle']).length > 0" + class="item-list-job-title"> + <span *ngFor="let value of allMetadataValues(['person.identifier.jobtitle']); let last=last;"> + <span [innerHTML]="value"><span [innerHTML]="value"></span></span> + </span> + </span> + </span> +</ng-template> +<ds-truncatable [id]="item.id"> + <a [routerLink]="['/items/' + item.id]" + [innerHTML]="firstMetadataValue('dc.contributor.author')" + [tooltip]="descTemplate"></a> +</ds-truncatable> diff --git a/src/app/shared/object-list/item-list-element/item-types/person/person-metadata-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/person/person-metadata-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..959673242de4a9d223e7f4be1445dcff653f75d3 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/person/person-metadata-list-element.component.ts @@ -0,0 +1,15 @@ +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { Component } from '@angular/core'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; +import { MetadataRepresentationType } from '../../../../../core/shared/metadata-representation/metadata-representation.model'; + +@rendersItemType('Person', ItemViewMode.Element, MetadataRepresentationType.Item) +@Component({ + selector: 'ds-person-metadata-list-element', + templateUrl: './person-metadata-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Person + */ +export class PersonMetadataListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..6f0faa90ef13cc6241760130d87d7196041ce119 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.html @@ -0,0 +1,16 @@ +<ds-truncatable [id]="item.id"> + <a + [routerLink]="['/items/' + item.id]" class="lead" + [innerHTML]="firstMetadataValue('project.identifier.name')"></a> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="1"> + <span *ngIf="item.allMetadata(['project.identifier.status']).length > 0" + class="item-list-status"> + <span *ngFor="let value of allMetadataValues(['project.identifier.status']); let last=last;"> + <span [innerHTML]="value"><span [innerHTML]="value"></span></span> + </span> + </span> + </ds-truncatable-part> + </span> +</ds-truncatable> + diff --git a/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.scss b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..5ab410dcb0f75817ae9ea534160253c7039e8ae0 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.scss @@ -0,0 +1 @@ +@import '../../../../../../styles/variables'; diff --git a/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1dd3c42042a6957b1d2550ec9eee03e9a446fcb1 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.spec.ts @@ -0,0 +1,87 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; +import { ProjectListElementComponent } from './project-list-element.component'; +import { of as observableOf } from 'rxjs'; + +let projectListElementComponent: ProjectListElementComponent; +let fixture: ComponentFixture<ProjectListElementComponent>; + +const mockItemWithMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'project.identifier.status': [ + { + language: 'en_US', + value: 'A status about the project' + } + ] + } +}); +const mockItemWithoutMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('ProjectListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ProjectListElementComponent , TruncatePipe], + providers: [ + { provide: ITEM, useValue: mockItemWithMetadata}, + { provide: TruncatableService, useValue: {} } + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(ProjectListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ProjectListElementComponent); + projectListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a status', () => { + beforeEach(() => { + projectListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the status span', () => { + const statusField = fixture.debugElement.query(By.css('span.item-list-status')); + expect(statusField).not.toBeNull(); + }); + }); + + describe('When the item has no status', () => { + beforeEach(() => { + projectListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the status span', () => { + const statusField = fixture.debugElement.query(By.css('span.item-list-status')); + expect(statusField).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..86b58c94fdffe25c985ed8e7308b50a6025bfabd --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/project/project-list-element.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; + +@rendersItemType('Project', ItemViewMode.Element) +@Component({ + selector: 'ds-project-list-element', + styleUrls: ['./project-list-element.component.scss'], + templateUrl: './project-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Project + */ +export class ProjectListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.html b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..aff19aec1d565f44ab5f198a31c1b83d3a4acdd7 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.html @@ -0,0 +1,24 @@ +<ds-truncatable [id]="item.id" *ngIf="item !== undefined && item !== null"> + <a + [routerLink]="['/items/' + item.id]" class="lead" + [innerHTML]="firstMetadataValue('dc.title')"></a> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="1"> + <ng-container *ngIf="item.firstMetadataValue('dc.publisher') || item.firstMetadataValue('dc.date.issued')">(<span class="item-list-publisher" + [innerHTML]="firstMetadataValue('dc.publisher')">, </span><span + *ngIf="item.firstMetadataValue('dc.date.issued')" class="item-list-date" + [innerHTML]="firstMetadataValue('dc.date.issued')"></span>)</ng-container> + <span *ngIf="item.allMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']).length > 0" + class="item-list-authors"> + <span *ngFor="let author of allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']); let last=last;"> + <span [innerHTML]="author"><span [innerHTML]="author"></span></span> + </span> + </span> + </ds-truncatable-part> + </span> + <div *ngIf="item.firstMetadataValue('dc.description.abstract')" class="item-list-abstract"> + <ds-truncatable-part [id]="item.id" [minLines]="3"><span + [innerHTML]="firstMetadataValue('dc.description.abstract')"></span> + </ds-truncatable-part> + </div> +</ds-truncatable> diff --git a/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.scss b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..5ab410dcb0f75817ae9ea534160253c7039e8ae0 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.scss @@ -0,0 +1 @@ +@import '../../../../../../styles/variables'; diff --git a/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..732fd0d4e40f3824ec19326002375286dadffd07 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.spec.ts @@ -0,0 +1,177 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { PublicationListElementComponent } from './publication-list-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; +import { of as observableOf } from 'rxjs'; + +let publicationListElementComponent: PublicationListElementComponent; +let fixture: ComponentFixture<PublicationListElementComponent>; + +const mockItemWithMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.publisher': [ + { + language: 'en_US', + value: 'a publisher' + } + ], + 'dc.date.issued': [ + { + language: 'en_US', + value: '2015-06-26' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is the abstract' + } + ] + } +}); +const mockItemWithoutMetadata: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('PublicationListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ PublicationListElementComponent , TruncatePipe], + providers: [ + { provide: ITEM, useValue: mockItemWithMetadata}, + { provide: TruncatableService, useValue: {} } + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(PublicationListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PublicationListElementComponent); + publicationListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has an author', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the author paragraph', () => { + const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); + expect(itemAuthorField).not.toBeNull(); + }); + }); + + describe('When the item has no author', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the author paragraph', () => { + const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); + expect(itemAuthorField).toBeNull(); + }); + }); + + describe('When the item has a publisher', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the publisher span', () => { + const publisherField = fixture.debugElement.query(By.css('span.item-list-publisher')); + expect(publisherField).not.toBeNull(); + }); + }); + + describe('When the item has no publisher', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the publisher span', () => { + const publisherField = fixture.debugElement.query(By.css('span.item-list-publisher')); + expect(publisherField).toBeNull(); + }); + }); + + describe('When the item has an issuedate', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the issuedate span', () => { + const dateField = fixture.debugElement.query(By.css('span.item-list-date')); + expect(dateField).not.toBeNull(); + }); + }); + + describe('When the item has no issuedate', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the issuedate span', () => { + const dateField = fixture.debugElement.query(By.css('span.item-list-date')); + expect(dateField).toBeNull(); + }); + }); + + describe('When the item has an abstract', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the abstract span', () => { + const abstractField = fixture.debugElement.query(By.css('div.item-list-abstract')); + expect(abstractField).not.toBeNull(); + }); + }); + + describe('When the item has no abstract', () => { + beforeEach(() => { + publicationListElementComponent.item = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the abstract span', () => { + const abstractField = fixture.debugElement.query(By.css('div.item-list-abstract')); + expect(abstractField).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..4717ff1df20a2b8cbb79dcb5b3f96f15387aa167 --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/publication/publication-list-element.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import { DEFAULT_ITEM_TYPE, ItemViewMode, rendersItemType } from '../../../../items/item-type-decorator'; +import { TypedItemSearchResultListElementComponent } from '../typed-item-search-result-list-element.component'; + +@rendersItemType('Publication', ItemViewMode.Element) +@rendersItemType(DEFAULT_ITEM_TYPE, ItemViewMode.Element) +@Component({ + selector: 'ds-publication-list-element', + styleUrls: ['./publication-list-element.component.scss'], + templateUrl: './publication-list-element.component.html' +}) +/** + * The component for displaying a list element for an item of the type Publication + */ +export class PublicationListElementComponent extends TypedItemSearchResultListElementComponent { +} diff --git a/src/app/shared/object-list/item-list-element/item-types/typed-item-search-result-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/typed-item-search-result-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f320ff2efc9ee656640168be5905777434aedc4f --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/typed-item-search-result-list-element.component.spec.ts @@ -0,0 +1,83 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TruncatePipe } from '../../../utils/truncate.pipe'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { TypedItemSearchResultListElementComponent } from './typed-item-search-result-list-element.component'; +import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { ITEM } from '../../../items/switcher/item-type-switcher.component'; +import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { createRelationshipsObservable } from '../../../../+item-page/simple/item-types/shared/item.component.spec'; +import { of as observableOf } from 'rxjs'; +import { MetadataMap } from '../../../../core/shared/metadata.models'; + +const mockItem: Item = Object.assign(new Item(), { + bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))), + metadata: [], + relationships: createRelationshipsObservable() +}); +const mockSearchResult = { + indexableObject: mockItem as Item, + hitHighlights: new MetadataMap() +} as ItemSearchResult; + +describe('ItemSearchResultComponent', () => { + let comp: TypedItemSearchResultListElementComponent; + let fixture: ComponentFixture<TypedItemSearchResultListElementComponent>; + + describe('when injecting an Item', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [TypedItemSearchResultListElementComponent, TruncatePipe], + providers: [ + {provide: TruncatableService, useValue: {}}, + {provide: ITEM, useValue: mockItem} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(TypedItemSearchResultListElementComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(TypedItemSearchResultListElementComponent); + comp = fixture.componentInstance; + })); + + it('should initiate item, object and dso correctly', () => { + expect(comp.item).toBe(mockItem); + expect(comp.dso).toBe(mockItem); + expect(comp.object.indexableObject).toBe(mockItem); + }) + }); + + describe('when injecting an ItemSearchResult', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [TypedItemSearchResultListElementComponent, TruncatePipe], + providers: [ + {provide: TruncatableService, useValue: {}}, + {provide: ITEM, useValue: mockSearchResult} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(TypedItemSearchResultListElementComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(TypedItemSearchResultListElementComponent); + comp = fixture.componentInstance; + })); + + it('should initiate item, object and dso correctly', () => { + expect(comp.item).toBe(mockItem); + expect(comp.dso).toBe(mockItem); + expect(comp.object.indexableObject).toBe(mockItem); + }) + }); +}); diff --git a/src/app/shared/object-list/item-list-element/item-types/typed-item-search-result-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-types/typed-item-search-result-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..7df3ab5681f065cf9d26029ce11fb18a34dca54a --- /dev/null +++ b/src/app/shared/object-list/item-list-element/item-types/typed-item-search-result-list-element.component.ts @@ -0,0 +1,37 @@ +import { Component, Inject } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { hasValue } from '../../../empty.util'; +import { ITEM } from '../../../items/switcher/item-type-switcher.component'; +import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; +import { MetadataMap } from '../../../../core/shared/metadata.models'; + +/** + * A generic component for displaying item list elements + */ +@Component({ + selector: 'ds-item-search-result', + template: '' +}) +export class TypedItemSearchResultListElementComponent extends SearchResultListElementComponent<ItemSearchResult, Item> { + item: Item; + + constructor( + protected truncatableService: TruncatableService, + @Inject(ITEM) public obj: Item | ItemSearchResult, + ) { + super(undefined, truncatableService); + if (hasValue((obj as any).indexableObject)) { + this.object = obj as ItemSearchResult; + this.dso = this.object.indexableObject; + } else { + this.object = { + indexableObject: obj as Item, + hitHighlights: new MetadataMap() + }; + this.dso = obj as Item; + } + this.item = this.dso; + } +} diff --git a/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..764fdc1064884d507ab35ec6d8e554a7c16ac605 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.html @@ -0,0 +1,2 @@ +<ds-item-type-switcher [object]="metadataRepresentation" [viewMode]="viewMode"> +</ds-item-type-switcher> diff --git a/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ffa0689511b50ad06b1af93e25ffa8ac4e07936 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ITEM } from '../../../items/switcher/item-type-switcher.component'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ItemMetadataListElementComponent } from './item-metadata-list-element.component'; +import { By } from '@angular/platform-browser'; +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; + +const mockItemMetadataRepresentation = new ItemMetadataRepresentation(); + +describe('ItemMetadataListElementComponent', () => { + let comp: ItemMetadataListElementComponent; + let fixture: ComponentFixture<ItemMetadataListElementComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [ItemMetadataListElementComponent], + providers: [ + { provide: ITEM, useValue: mockItemMetadataRepresentation } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemMetadataListElementComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemMetadataListElementComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should call an item-type-switcher component and pass the item-metadata-representation', () => { + const itemTypeSwitcher = fixture.debugElement.query(By.css('ds-item-type-switcher')).nativeElement; + expect(itemTypeSwitcher.object).toBe(mockItemMetadataRepresentation); + }); + +}); diff --git a/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..084567a885aa31fffe84f4e555c6482a3711c19b --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/item/item-metadata-list-element.component.ts @@ -0,0 +1,22 @@ +import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { Component } from '@angular/core'; +import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; +import { DEFAULT_ITEM_TYPE, ItemViewMode, rendersItemType } from '../../../items/item-type-decorator'; + +@rendersItemType(DEFAULT_ITEM_TYPE, ItemViewMode.Metadata, MetadataRepresentationType.Item) +@Component({ + selector: 'ds-item-metadata-list-element', + templateUrl: './item-metadata-list-element.component.html' +}) +/** + * A component for displaying MetadataRepresentation objects in the form of items + * It will send the MetadataRepresentation object along with ElementViewMode.SetElement to the ItemTypeSwitcherComponent, + * which will in his turn decide how to render the item as metadata. + */ +export class ItemMetadataListElementComponent extends MetadataRepresentationListElementComponent { + /** + * The view-mode we're currently on + * @type {ElementViewMode} + */ + viewMode = ItemViewMode.Element; +} diff --git a/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..2488db50b17ac9b79fc63ff522b842c4b6a6dcd1 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts @@ -0,0 +1,15 @@ +import { Component, Inject } from '@angular/core'; +import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; +import { ITEM } from '../../items/switcher/item-type-switcher.component'; + +@Component({ + selector: 'ds-metadata-representation-list-element', + template: '' +}) +/** + * An abstract class for displaying a single MetadataRepresentation + */ +export class MetadataRepresentationListElementComponent { + constructor(@Inject(ITEM) public metadataRepresentation: MetadataRepresentation) { + } +} diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..3e017e1ae8654e2da856cf478923edfd3b4a6c36 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html @@ -0,0 +1,3 @@ +<div> + <span>{{metadataRepresentation.getValue()}}</span> +</div> diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..42b9abde16bccd63cb5fe737197a0fc754762419 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ITEM } from '../../../items/switcher/item-type-switcher.component'; + +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'Test Author' +}); + +describe('PlainTextMetadataListElementComponent', () => { + let comp: PlainTextMetadataListElementComponent; + let fixture: ComponentFixture<PlainTextMetadataListElementComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [PlainTextMetadataListElementComponent], + providers: [ + { provide: ITEM, useValue: mockMetadataRepresentation } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PlainTextMetadataListElementComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PlainTextMetadataListElementComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should contain the value as plain text', () => { + expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); + }); + +}); diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c4785e37c9f04fcb37f2fb44058c4edb9bf60ae --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts @@ -0,0 +1,18 @@ +import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { Component } from '@angular/core'; +import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; +import { DEFAULT_ITEM_TYPE, ItemViewMode, rendersItemType } from '../../../items/item-type-decorator'; + +@rendersItemType(DEFAULT_ITEM_TYPE, ItemViewMode.Metadata, MetadataRepresentationType.PlainText) +// For now, authority controlled fields are rendered the same way as plain text fields +@rendersItemType(DEFAULT_ITEM_TYPE, ItemViewMode.Metadata, MetadataRepresentationType.AuthorityControlled) +@Component({ + selector: 'ds-plain-text-metadata-list-element', + templateUrl: './plain-text-metadata-list-element.component.html' +}) +/** + * A component for displaying MetadataRepresentation objects in the form of plain text + * It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text + */ +export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent { +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..5ec4c9a5b5f6ff20ffbd6bc25cd1a58c1d25df7e --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.html @@ -0,0 +1,7 @@ +<ds-item-list-preview *ngIf="workflowitem" + [item]="(workflowitem.item | async)?.payload" + [object]="object" + [showSubmitter]="showSubmitter" + [status]="status"></ds-item-list-preview> + +<ds-claimed-task-actions *ngIf="workflowitem" [object]="dso"></ds-claimed-task-actions> diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c30b2ef8c8d475ed528f7826a2afd0924f7af80 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.spec.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { ClaimedMyDSpaceResultListElementComponent } from './claimed-my-dspace-result-list-element.component'; +import { ClaimedTaskMyDSpaceResult } from '../../../object-collection/shared/claimed-task-my-dspace-result.model'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; + +let component: ClaimedMyDSpaceResultListElementComponent; +let fixture: ComponentFixture<ClaimedMyDSpaceResultListElementComponent>; + +const compIndex = 1; + +const mockResultObject: ClaimedTaskMyDSpaceResult = new ClaimedTaskMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = new RemoteData(false, false, true, null, item); +const workflowitem = Object.assign(new Workflowitem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); + +describe('ClaimedMyDSpaceResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ClaimedMyDSpaceResultListElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedMyDSpaceResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ClaimedMyDSpaceResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.workflowitem).toEqual(workflowitem); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.VALIDATION); + }); +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..0725f98897e355abb3d0dd73d16977b3e10f6557 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component.ts @@ -0,0 +1,64 @@ +import { Component } from '@angular/core'; +import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { MyDSpaceResultListElementComponent, } from '../my-dspace-result-list-element.component'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotUndefined } from '../../../empty.util'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { ClaimedTaskMyDSpaceResult } from '../../../object-collection/shared/claimed-task-my-dspace-result.model'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders claimed task object for the mydspace result in the list view. + */ +@Component({ + selector: 'ds-claimed-my-dspace-result-list-element', + styleUrls: ['../my-dspace-result-list-element.component.scss'], + templateUrl: './claimed-my-dspace-result-list-element.component.html', + providers: [Location, { provide: LocationStrategy, useClass: PathLocationStrategy }] +}) + +@renderElementsFor(ClaimedTaskMyDSpaceResult, SetViewMode.List) +@renderElementsFor(ClaimedTask, SetViewMode.List) +export class ClaimedMyDSpaceResultListElementComponent extends MyDSpaceResultListElementComponent<ClaimedTaskMyDSpaceResult, ClaimedTask> { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.VALIDATION; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitem: Workflowitem; + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initWorkflowItem(this.dso.workflowitem as Observable<RemoteData<Workflowitem>>); + } + + /** + * Retrieve workflowitem from result object + */ + initWorkflowItem(wfi$: Observable<RemoteData<Workflowitem>>) { + wfi$.pipe( + find((rd: RemoteData<Workflowitem>) => (rd.hasSucceeded && isNotUndefined(rd.payload))) + ).subscribe((rd: RemoteData<Workflowitem>) => { + this.workflowitem = rd.payload; + }); + } +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html new file mode 100644 index 0000000000000000000000000000000000000000..bcd5c3c02715cd0832e69c209044aebdfa4a5eed --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -0,0 +1,32 @@ +<ng-container *ngIf="item" @fadeInOut> + <ng-container *ngIf="status"> + <ds-mydspace-item-status [status]="status"></ds-mydspace-item-status> + </ng-container> + <ds-truncatable [id]="item.id"> + <h3 [innerHTML]="item.firstMetadataValue('dc.title') || ('mydspace.results.no-title' | translate)" [ngClass]="{'lead': true,'text-muted': !item.firstMetadataValue('dc.title')}"></h3> + <div> + <span class="text-muted"> + <ds-truncatable-part [id]="item.id" [minLines]="1"> + (<span *ngIf="item.hasMetadata('dc.publisher')" class="item-list-publisher" + [innerHTML]="item.firstMetadataValue('dc.publisher') + ', '"></span> + <span class="item-list-date" [innerHTML]="item.firstMetadataValue('dc.date.issued') || ('mydspace.results.no-date' | translate)"></span>) + <span *ngIf="item.hasMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']);" + class="item-list-authors"> + <span *ngIf="item.allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']).length === 0">{{'mydspace.results.no-authors' | translate}}</span> + <span *ngFor="let author of item.allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']); let last=last;"> + <span [innerHTML]="author"><span [innerHTML]="author"></span></span> + </span> + </span> + + </ds-truncatable-part> + </span> + + <ds-truncatable-part [id]="item.id" [minLines]="1" class="item-list-abstract"> + <span [ngClass]="{'text-muted': !item.firstMetadataValue('dc.description.abstract')}" + [innerHTML]="(item.firstMetadataValue('dc.description.abstract')) || ('mydspace.results.no-abstract' | translate)"></span> + </ds-truncatable-part> + + </div> + </ds-truncatable> + <ds-item-submitter *ngIf="showSubmitter" [object]="object.indexableObject"></ds-item-submitter> +</ng-container> diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.scss b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..17b0279c7a54088682fe13a0ae61e618522861c3 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts @@ -0,0 +1,131 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; + +import { TruncatePipe } from '../../../utils/truncate.pipe'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemListPreviewComponent } from './item-list-preview.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; + +let component: ItemListPreviewComponent; +let fixture: ComponentFixture<ItemListPreviewComponent>; + +const mockItemWithAuthorAndDate: Item = Object.assign(new Item(), { + bitstreams: observableOf({}), + 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: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } +}); + +describe('ItemListPreviewComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [ItemListPreviewComponent, TruncatePipe], + providers: [ + { provide: 'objectElementProvider', useValue: { mockItemWithAuthorAndDate } } + + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemListPreviewComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemListPreviewComponent); + component = fixture.componentInstance; + + })); + + beforeEach(() => { + component.object = { hitHighlights: {} } as any; + }); + + describe('When the item has an author', () => { + beforeEach(() => { + component.item = mockItemWithAuthorAndDate; + fixture.detectChanges(); + }); + + it('should show the author paragraph', () => { + const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); + expect(itemAuthorField).not.toBeNull(); + }); + }); + + describe('When the item has no author', () => { + beforeEach(() => { + component.item = mockItemWithoutAuthorAndDate; + fixture.detectChanges(); + }); + + it('should not show the author paragraph', () => { + const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); + expect(itemAuthorField).toBeNull(); + }); + }); + + describe('When the item has an issuedate', () => { + beforeEach(() => { + component.item = mockItemWithAuthorAndDate; + fixture.detectChanges(); + }); + + it('should show the issuedate span', () => { + const dateField = fixture.debugElement.query(By.css('span.item-list-date')); + expect(dateField).not.toBeNull(); + }); + }); + + describe('When the item has no issuedate', () => { + beforeEach(() => { + component.item = mockItemWithoutAuthorAndDate; + fixture.detectChanges(); + }); + + it('should show the issuedate empty placeholder', () => { + const dateField = fixture.debugElement.query(By.css('span.item-list-date')); + expect(dateField).not.toBeNull(); + }); + }); +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..13876ab46a5444aa43b8caef1e634d6158cca689 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -0,0 +1,39 @@ +import { Component, Input } from '@angular/core'; + +import { Item } from '../../../../core/shared/item.model'; +import { fadeInOut } from '../../../animations/fade'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { MyDSpaceResult } from '../../../../+my-dspace-page/my-dspace-result.model'; + +/** + * This component show metadata for the given item object in the list view. + */ +@Component({ + selector: 'ds-item-list-preview', + styleUrls: ['item-list-preview.component.scss'], + templateUrl: 'item-list-preview.component.html', + animations: [fadeInOut] +}) +export class ItemListPreviewComponent { + + /** + * The item to display + */ + @Input() item: Item; + + /** + * The mydspace result object + */ + @Input() object: MyDSpaceResult<any>; + + /** + * Represent item's status + */ + @Input() status: MyDspaceItemStatusType; + + /** + * A boolean representing if to show submitter information + */ + @Input() showSubmitter = false; + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..e1b14354814627d6c93e6521d194b24d2a8c5a48 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.html @@ -0,0 +1,5 @@ +<ds-item-list-preview [item]="dso" + [object]="object" + [status]="status"></ds-item-list-preview> + +<ds-item-actions [object]="dso"></ds-item-actions> diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.scss b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..1d0786105ca4dc61051610513a3fdbe21424a6d5 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables'; diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea4f2d24f3d56a4301774b3f4063e405a2cc15d9 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.spec.ts @@ -0,0 +1,78 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { ItemMyDSpaceResultListElementComponent } from './item-my-dspace-result-list-element.component'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { ItemMyDSpaceResult } from '../../../object-collection/shared/item-my-dspace-result.model'; + +let component: ItemMyDSpaceResultListElementComponent; +let fixture: ComponentFixture<ItemMyDSpaceResultListElementComponent>; + +const compIndex = 1; + +const mockResultObject: ItemMyDSpaceResult = new ItemMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +mockResultObject.indexableObject = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); + +describe('ItemMyDSpaceResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ItemMyDSpaceResultListElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ItemMyDSpaceResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ItemMyDSpaceResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.ARCHIVED); + }); +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f2a43960302028982d00054b138fdcf98f69d44 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { MyDSpaceResultListElementComponent, } from '../my-dspace-result-list-element.component'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemMyDSpaceResult } from '../../../object-collection/shared/item-my-dspace-result.model'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders item object for the mydspace result in the list view. + */ +@Component({ + selector: 'ds-workspaceitem-my-dspace-result-list-element', + styleUrls: ['../my-dspace-result-list-element.component.scss', './item-my-dspace-result-list-element.component.scss'], + templateUrl: './item-my-dspace-result-list-element.component.html' +}) + +@renderElementsFor(ItemMyDSpaceResult, SetViewMode.List) +export class ItemMyDSpaceResultListElementComponent extends MyDSpaceResultListElementComponent<ItemMyDSpaceResult, Item> { + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.ARCHIVED; + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/my-dspace-result-list-element.component.scss b/src/app/shared/object-list/my-dspace-result-list-element/my-dspace-result-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..4cd8a2b6977405d2118b9225410f190029161328 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/my-dspace-result-list-element.component.scss @@ -0,0 +1 @@ +@import '../search-result-list-element/search-result-list-element.component.scss'; diff --git a/src/app/shared/object-list/my-dspace-result-list-element/my-dspace-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/my-dspace-result-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2a5bf14fdd1c904763633bb216a0af3ad297504 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/my-dspace-result-list-element.component.ts @@ -0,0 +1,58 @@ +import { Component, Inject } from '@angular/core'; + +import { MyDSpaceResult } from '../../../+my-dspace-page/my-dspace-result.model'; +import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { ListableObject } from '../../object-collection/shared/listable-object.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; + +@Component({ + selector: 'ds-my-dspace-result-list-element', + template: `` +}) +export class MyDSpaceResultListElementComponent<T extends MyDSpaceResult<K>, K extends DSpaceObject> extends AbstractListableElementComponent<T> { + + /** + * The result element object + */ + dso: K; + + /** + * The array index of the result element + */ + dsoIndex: number; + + /** + * Initialize instance variables + * + * @param {ListableObject} listable + * @param {number} index + */ + public constructor(@Inject('objectElementProvider') public listable: ListableObject, + @Inject('indexElementProvider') public index: number) { + super(listable); + this.dso = this.object.indexableObject; + this.dsoIndex = this.index; + } + + /** + * 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); + } + + /** + * 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); + } + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..5f0f1bb6d4845cc932afdb9427cd047c79efa4a3 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.html @@ -0,0 +1,7 @@ +<ds-item-list-preview *ngIf="workflowitem" + [item]="(workflowitem.item | async)?.payload" + [object]="object" + [showSubmitter]="showSubmitter" + [status]="status"></ds-item-list-preview> + +<ds-pool-task-actions [object]="dso"></ds-pool-task-actions> diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..36eb8f253ad7c0ac4407d3e2a2ec069df58086bd --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.spec.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { PoolMyDSpaceResultListElementComponent } from './pool-my-dspace-result-list-element.component'; +import { PoolTaskMyDSpaceResult } from '../../../object-collection/shared/pool-task-my-dspace-result.model'; +import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; + +let component: PoolMyDSpaceResultListElementComponent; +let fixture: ComponentFixture<PoolMyDSpaceResultListElementComponent>; + +const compIndex = 1; + +const mockResultObject: PoolTaskMyDSpaceResult = new PoolTaskMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = new RemoteData(false, false, true, null, item); +const workflowitem = Object.assign(new Workflowitem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = new RemoteData(false, false, true, null, workflowitem); +mockResultObject.indexableObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem) }); + +describe('PoolMyDSpaceResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [PoolMyDSpaceResultListElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PoolMyDSpaceResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PoolMyDSpaceResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.workflowitem).toEqual(workflowitem); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.WAITING_CONTROLLER); + }); +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d78a7e2e8e2087957529689b1cacd79bb43b064 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component.ts @@ -0,0 +1,68 @@ +import { Component, Inject, OnInit } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { MyDSpaceResultListElementComponent, } from '../my-dspace-result-list-element.component'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotUndefined } from '../../../empty.util'; +import { ListableObject } from '../../../object-collection/shared/listable-object.model'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; +import { PoolTaskMyDSpaceResult } from '../../../object-collection/shared/pool-task-my-dspace-result.model'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders pool task object for the mydspace result in the list view. + */ +@Component({ + selector: 'ds-pool-my-dspace-result-list-element', + styleUrls: ['../my-dspace-result-list-element.component.scss'], + templateUrl: './pool-my-dspace-result-list-element.component.html', +}) + +@renderElementsFor(PoolTaskMyDSpaceResult, SetViewMode.List) +@renderElementsFor(PoolTask, SetViewMode.List) +export class PoolMyDSpaceResultListElementComponent extends MyDSpaceResultListElementComponent<PoolTaskMyDSpaceResult, PoolTask> implements OnInit { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.WAITING_CONTROLLER; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitem: Workflowitem; + + constructor(@Inject('objectElementProvider') public listable: ListableObject, + @Inject('indexElementProvider') public index: number) { + super(listable, index); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initWorkflowItem(this.dso.workflowitem as Observable<RemoteData<Workflowitem>>); + } + + /** + * Retrieve workflowitem from result object + */ + initWorkflowItem(wfi$: Observable<RemoteData<Workflowitem>>) { + wfi$.pipe( + find((rd: RemoteData<Workflowitem>) => (rd.hasSucceeded && isNotUndefined(rd.payload))) + ).subscribe((rd: RemoteData<Workflowitem>) => { + this.workflowitem = rd.payload; + }); + } +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..782c5f9e56dd70db3a93262d7e92a958f5b212c6 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.html @@ -0,0 +1,6 @@ +<ds-item-list-preview [item]="item" + [object]="object" + [status]="status"></ds-item-list-preview> + +<ds-workflowitem-actions [object]="dso"></ds-workflowitem-actions> + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bcd4d46b07c3a43d12f17df3d05e197107c5c94 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.spec.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { WorkflowitemMyDSpaceResultListElementComponent } from './workflowitem-my-dspace-result-list-element.component'; +import { WorkflowitemMyDSpaceResult } from '../../../object-collection/shared/workflowitem-my-dspace-result.model'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; + +let component: WorkflowitemMyDSpaceResultListElementComponent; +let fixture: ComponentFixture<WorkflowitemMyDSpaceResultListElementComponent>; + +const compIndex = 1; + +const mockResultObject: WorkflowitemMyDSpaceResult = new WorkflowitemMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rd = new RemoteData(false, false, true, null, item); +mockResultObject.indexableObject = Object.assign(new Workflowitem(), { item: observableOf(rd) }); + +describe('WorkflowitemMyDSpaceResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [WorkflowitemMyDSpaceResultListElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(WorkflowitemMyDSpaceResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(WorkflowitemMyDSpaceResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.item).toEqual(item); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.WORKFLOW); + }); +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a5038225cb0a8a39dddba86c3be18a1bf5bb2b0 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component.ts @@ -0,0 +1,58 @@ +import { Component } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { MyDSpaceResultListElementComponent, } from '../my-dspace-result-list-element.component'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotUndefined } from '../../../empty.util'; +import { WorkflowitemMyDSpaceResult } from '../../../object-collection/shared/workflowitem-my-dspace-result.model'; +import { Workflowitem } from '../../../../core/submission/models/workflowitem.model'; +import { Item } from '../../../../core/shared/item.model'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders workflowitem object for the mydspace result in the list view. + */ +@Component({ + selector: 'ds-workflowitem-my-dspace-result-list-element', + styleUrls: ['../my-dspace-result-list-element.component.scss'], + templateUrl: './workflowitem-my-dspace-result-list-element.component.html', +}) + +@renderElementsFor(WorkflowitemMyDSpaceResult, SetViewMode.List) +@renderElementsFor(Workflowitem, SetViewMode.List) +export class WorkflowitemMyDSpaceResultListElementComponent extends MyDSpaceResultListElementComponent<WorkflowitemMyDSpaceResult, Workflowitem> { + + /** + * The item object that belonging to the result object + */ + public item: Item; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.WORKFLOW; + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initItem(this.dso.item as Observable<RemoteData<Item>>); + } + + /** + * Retrieve item from result object + */ + initItem(item$: Observable<RemoteData<Item>>) { + item$.pipe( + find((rd: RemoteData<Item>) => rd.hasSucceeded && isNotUndefined(rd.payload)) + ).subscribe((rd: RemoteData<Item>) => { + this.item = rd.payload; + }); + } + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.html new file mode 100644 index 0000000000000000000000000000000000000000..79a31770d6a6d2f2df3dbc242063bc802d84d2bb --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.html @@ -0,0 +1,7 @@ +<ds-item-list-preview + *ngIf="status && item" + [item]="item" + [object]="object" + [status]="status"></ds-item-list-preview> + +<ds-workspaceitem-actions [object]="dso"></ds-workspaceitem-actions> diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.scss b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..a32ecc6925bbf1049aebd493a14cfd63bd535e95 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.scss @@ -0,0 +1,20 @@ +@import '../../../../../styles/variables'; + +::-webkit-scrollbar-track +{ + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); + background-color: #F5F5F5; +} + +::-webkit-scrollbar +{ + width: 12px; + background-color: #F5F5F5; +} + +::-webkit-scrollbar-thumb +{ + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3); + background-color: #555; +} + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..789b69da1b2c53dfbd7ccc65a5ebe2f73859d3e9 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.spec.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../core/shared/item.model'; +import { WorkspaceitemMyDSpaceResultListElementComponent } from './workspaceitem-my-dspace-result-list-element.component'; +import { WorkspaceitemMyDSpaceResult } from '../../../object-collection/shared/workspaceitem-my-dspace-result.model'; +import { Workspaceitem } from '../../../../core/submission/models/workspaceitem.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; + +let component: WorkspaceitemMyDSpaceResultListElementComponent; +let fixture: ComponentFixture<WorkspaceitemMyDSpaceResultListElementComponent>; + +const compIndex = 1; + +const mockResultObject: WorkspaceitemMyDSpaceResult = new WorkspaceitemMyDSpaceResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bitstreams: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rd = new RemoteData(false, false, true, null, item); +mockResultObject.indexableObject = Object.assign(new Workspaceitem(), { item: observableOf(rd) }); + +describe('WorkspaceitemMyDSpaceResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [WorkspaceitemMyDSpaceResultListElementComponent], + providers: [ + { provide: 'objectElementProvider', useValue: (mockResultObject) }, + { provide: 'indexElementProvider', useValue: (compIndex) } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(WorkspaceitemMyDSpaceResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(WorkspaceitemMyDSpaceResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init item properly', () => { + expect(component.item).toEqual(item); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.WORKSPACE); + }); +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ea647b4ba40a0d6a96eeefc01528dcb6b92af52 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component.ts @@ -0,0 +1,56 @@ +import { Component } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; +import { MyDSpaceResultListElementComponent, } from '../my-dspace-result-list-element.component'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { Workspaceitem } from '../../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemMyDSpaceResult } from '../../../object-collection/shared/workspaceitem-my-dspace-result.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotUndefined } from '../../../empty.util'; +import { Item } from '../../../../core/shared/item.model'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { SetViewMode } from '../../../view-mode'; + +/** + * This component renders workspaceitem object for the mydspace result in the list view. + */ +@Component({ + selector: 'ds-workspaceitem-my-dspace-result-list-element', + styleUrls: ['../my-dspace-result-list-element.component.scss', './workspaceitem-my-dspace-result-list-element.component.scss'], + templateUrl: './workspaceitem-my-dspace-result-list-element.component.html', +}) + +@renderElementsFor(WorkspaceitemMyDSpaceResult, SetViewMode.List) +export class WorkspaceitemMyDSpaceResultListElementComponent extends MyDSpaceResultListElementComponent<WorkspaceitemMyDSpaceResult, Workspaceitem> { + + /** + * The item object that belonging to the result object + */ + item: Item; + + /** + * Represent item's status + */ + status = MyDspaceItemStatusType.WORKSPACE; + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.initItem(this.dso.item as Observable<RemoteData<Item>>); + } + + /** + * Retrieve item from result object + */ + initItem(item$: Observable<RemoteData<Item>>) { + item$.pipe( + find((rd: RemoteData<Item>) => rd.hasSucceeded && isNotUndefined(rd.payload)) + ).subscribe((rd: RemoteData<Item>) => { + this.item = rd.payload; + }); + } +} diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 420886668a744c687ab327d3d397bdd0f70d5b92..1fdc06d5bf5cca762d4df7f53741cabcfbb4e19d 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -11,8 +11,8 @@ (sortFieldChange)="onSortFieldChange($event)" (paginationChange)="onPaginationChange($event)"> <ul *ngIf="objects?.hasSucceeded" class="list-unstyled"> - <li *ngFor="let object of objects?.payload?.page" class="mt-4 mb-4"> - <ds-wrapper-list-element [object]="object"></ds-wrapper-list-element> + <li *ngFor="let object of objects?.payload?.page; let i = index; let last = last" class="mt-4 mb-4" [class.border-bottom]="hasBorder && !last"> + <ds-wrapper-list-element [object]="object" [index]="i"></ds-wrapper-list-element> </li> </ul> </ds-pagination> diff --git a/src/app/shared/object-list/object-list.component.spec.ts b/src/app/shared/object-list/object-list.component.spec.ts index 7e0b704a19846cfdef6a6b954be1be4719304449..12ad032e981eb3db6b2ccdcc5e902994015fe425 100644 --- a/src/app/shared/object-list/object-list.component.spec.ts +++ b/src/app/shared/object-list/object-list.component.spec.ts @@ -6,7 +6,7 @@ import { By } from '@angular/platform-browser'; describe('ObjectListComponent', () => { let comp: ObjectListComponent; let fixture: ComponentFixture<ObjectListComponent>; - const testEvent = {test: 'test'} + const testEvent = {test: 'test'}; beforeEach(async(() => { TestBed.configureTestingModule({ diff --git a/src/app/shared/object-list/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index b0296d5ae157a1424200ab2e2728c56337cee0fd..afc376034fff9cf09291523b93d19608de7c32b6 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -25,6 +25,7 @@ export class ObjectListComponent { @Input() config: PaginationComponentOptions; @Input() sortConfig: SortOptions; + @Input() hasBorder = false; @Input() hideGear = false; @Input() hidePagerWhenSinglePage = true; private _objects: RemoteData<PaginatedList<ListableObject>>; 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 e897071a001e07da882f67a7262ca92eb980617e..7f5aaf5d9cd6d0263550e43b4c73fb1ccd84b30b 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 @@ -17,7 +17,7 @@ const truncatableServiceStub: any = { const mockCollectionWithAbstract: CollectionSearchResult = new CollectionSearchResult(); mockCollectionWithAbstract.hitHighlights = {}; -mockCollectionWithAbstract.dspaceObject = Object.assign(new Collection(), { +mockCollectionWithAbstract.indexableObject = Object.assign(new Collection(), { metadata: { 'dc.description.abstract': [ { @@ -30,7 +30,7 @@ mockCollectionWithAbstract.dspaceObject = Object.assign(new Collection(), { const mockCollectionWithoutAbstract: CollectionSearchResult = new CollectionSearchResult(); mockCollectionWithoutAbstract.hitHighlights = {}; -mockCollectionWithoutAbstract.dspaceObject = Object.assign(new Collection(), { +mockCollectionWithoutAbstract.indexableObject = Object.assign(new Collection(), { metadata: { 'dc.title': [ { @@ -63,7 +63,7 @@ describe('CollectionSearchResultListElementComponent', () => { describe('When the collection has an abstract', () => { beforeEach(() => { - collectionSearchResultListElementComponent.dso = mockCollectionWithAbstract.dspaceObject; + collectionSearchResultListElementComponent.dso = mockCollectionWithAbstract.indexableObject; fixture.detectChanges(); }); @@ -75,7 +75,7 @@ describe('CollectionSearchResultListElementComponent', () => { describe('When the collection has no abstract', () => { beforeEach(() => { - collectionSearchResultListElementComponent.dso = mockCollectionWithoutAbstract.dspaceObject; + collectionSearchResultListElementComponent.dso = mockCollectionWithoutAbstract.indexableObject; fixture.detectChanges(); }); diff --git a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts index 264f9ce1a980cdb54fc3318def361a1efbf841e4..2205155bbd47972d70798261e6e3abe396d52c16 100644 --- a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts @@ -4,8 +4,8 @@ import { renderElementsFor } from '../../../object-collection/shared/dso-element import { SearchResultListElementComponent } from '../search-result-list-element.component'; import { Collection } from '../../../../core/shared/collection.model'; +import { SetViewMode } from '../../../view-mode'; import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; @Component({ selector: 'ds-collection-search-result-list-element', @@ -13,5 +13,5 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; templateUrl: 'collection-search-result-list-element.component.html' }) -@renderElementsFor(CollectionSearchResult, ViewMode.List) +@renderElementsFor(CollectionSearchResult, SetViewMode.List) export class CollectionSearchResultListElementComponent extends SearchResultListElementComponent<CollectionSearchResult, Collection> {} 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 75d5966767e2c7517c3c0e0d66f3fdca0c4f2ad5..691a69dde4dd32af909e2e154bba74e091a7ce76 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 @@ -17,7 +17,7 @@ const truncatableServiceStub: any = { const mockCommunityWithAbstract: CommunitySearchResult = new CommunitySearchResult(); mockCommunityWithAbstract.hitHighlights = {}; -mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { +mockCommunityWithAbstract.indexableObject = Object.assign(new Community(), { metadata: { 'dc.description.abstract': [ { @@ -30,7 +30,7 @@ mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { const mockCommunityWithoutAbstract: CommunitySearchResult = new CommunitySearchResult(); mockCommunityWithoutAbstract.hitHighlights = {}; -mockCommunityWithoutAbstract.dspaceObject = Object.assign(new Community(), { +mockCommunityWithoutAbstract.indexableObject = Object.assign(new Community(), { metadata: { 'dc.title': [ { @@ -63,7 +63,7 @@ describe('CommunitySearchResultListElementComponent', () => { describe('When the community has an abstract', () => { beforeEach(() => { - communitySearchResultListElementComponent.dso = mockCommunityWithAbstract.dspaceObject; + communitySearchResultListElementComponent.dso = mockCommunityWithAbstract.indexableObject; fixture.detectChanges(); }); @@ -75,7 +75,7 @@ describe('CommunitySearchResultListElementComponent', () => { describe('When the community has no abstract', () => { beforeEach(() => { - communitySearchResultListElementComponent.dso = mockCommunityWithoutAbstract.dspaceObject; + communitySearchResultListElementComponent.dso = mockCommunityWithoutAbstract.indexableObject; fixture.detectChanges(); }); diff --git a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts index 227ff9a45d1ab484b46a7c7512a9104194c4c571..3f7e08d11c81e480a86e8af5457f904e16a9ade0 100644 --- a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts @@ -4,8 +4,8 @@ import { renderElementsFor } from '../../../object-collection/shared/dso-element import { SearchResultListElementComponent } from '../search-result-list-element.component'; import { Community } from '../../../../core/shared/community.model'; +import { SetViewMode } from '../../../view-mode'; import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; @Component({ selector: 'ds-community-search-result-list-element', @@ -13,7 +13,7 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; templateUrl: 'community-search-result-list-element.component.html' }) -@renderElementsFor(CommunitySearchResult, ViewMode.List) +@renderElementsFor(CommunitySearchResult, SetViewMode.List) export class CommunitySearchResultListElementComponent extends SearchResultListElementComponent<CommunitySearchResult, Community> { } 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 6261220459e8ccbc4c2649127a8aa0f7df853f77..a2617a956f2a316ced6592f8cb2fb45e51c1bf26 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,4 @@ -<ds-truncatable [id]="dso.id"> - <a - [routerLink]="['/items/' + dso.id]" class="lead" - [innerHTML]="firstMetadataValue('dc.title')"></a> - <span class="text-muted"> - <ds-truncatable-part [id]="dso.id" [minLines]="1"> - (<span *ngIf="dso.hasMetadata('dc.publisher')" class="item-list-publisher" - [innerHTML]="firstMetadataValue('dc.publisher') + ', '"></span><span - *ngIf="dso.hasMetadata('dc.date.issued')" class="item-list-date" - [innerHTML]="firstMetadataValue('dc.date.issued')"></span>) - <span *ngIf="dso.hasMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'])" - class="item-list-authors"> - <span *ngFor="let author of allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']); let last=last;"> - <span [innerHTML]="author"><span [innerHTML]="author"></span></span> - </span> - </span> - </ds-truncatable-part> - </span> - <div *ngIf="dso.hasMetadata('dc.description.abstract')" class="item-list-abstract"> - <ds-truncatable-part [id]="dso.id" [minLines]="3"><span - [innerHTML]="firstMetadataValue('dc.description.abstract')"></span> - </ds-truncatable-part> - </div> -</ds-truncatable> +<div *ngIf="object && object.indexableObject && object.indexableObject.firstMetadataValue('relationship.type') as type"> + <span class="badge badge-light">{{ type.toLowerCase() + '.listelement.badge' | translate }}</span> +</div> +<ds-item-type-switcher [object]="object" [viewMode]="viewMode"></ds-item-type-switcher> 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 8567fc17825d1e7bee78649013e18727d8da09e4..ef4660fdd9740849a86f39fe3d43bbbbc1f661d3 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 @@ -8,6 +8,7 @@ import { Item } from '../../../../core/shared/item.model'; import { TruncatableService } from '../../../truncatable/truncatable.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { TranslateModule } from '@ngx-translate/core'; let itemSearchResultListElementComponent: ItemSearchResultListElementComponent; let fixture: ComponentFixture<ItemSearchResultListElementComponent>; @@ -16,29 +17,25 @@ const truncatableServiceStub: any = { isCollapsed: (id: number) => observableOf(true), }; -const mockItemWithAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithAuthorAndDate.hitHighlights = {}; -mockItemWithAuthorAndDate.dspaceObject = Object.assign(new Item(), { +const type = 'authorOfPublication'; + +const mockItemWithRelationshipType: ItemSearchResult = new ItemSearchResult(); +mockItemWithRelationshipType.hitHighlights = {}; +mockItemWithRelationshipType.indexableObject = Object.assign(new Item(), { bitstreams: observableOf({}), metadata: { - 'dc.contributor.author': [ + 'relationship.type': [ { language: 'en_US', - value: 'Smith, Donald' - } - ], - 'dc.date.issued': [ - { - language: null, - value: '2015-06-26' + value: type } ] } }); -const mockItemWithoutAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithoutAuthorAndDate.hitHighlights = {}; -mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { +const mockItemWithoutRelationshipType: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutRelationshipType.hitHighlights = {}; +mockItemWithoutRelationshipType.indexableObject = Object.assign(new Item(), { bitstreams: observableOf({}), metadata: { 'dc.title': [ @@ -46,12 +43,6 @@ mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { language: 'en_US', value: 'This is just another title' } - ], - 'dc.type': [ - { - language: null, - value: 'Article' - } ] } }); @@ -59,11 +50,11 @@ mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { describe('ItemSearchResultListElementComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule], + imports: [TranslateModule.forRoot(), NoopAnimationsModule], declarations: [ItemSearchResultListElementComponent, TruncatePipe], providers: [ { provide: TruncatableService, useValue: truncatableServiceStub }, - { provide: 'objectElementProvider', useValue: (mockItemWithoutAuthorAndDate) } + { provide: 'objectElementProvider', useValue: (mockItemWithoutRelationshipType) } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ItemSearchResultListElementComponent, { @@ -76,51 +67,28 @@ describe('ItemSearchResultListElementComponent', () => { itemSearchResultListElementComponent = fixture.componentInstance; })); - describe('When the item has an author', () => { - beforeEach(() => { - itemSearchResultListElementComponent.dso = mockItemWithAuthorAndDate.dspaceObject; - fixture.detectChanges(); - }); - - it('should show the author paragraph', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); - expect(itemAuthorField).not.toBeNull(); - }); - }); - - describe('When the item has no author', () => { - beforeEach(() => { - itemSearchResultListElementComponent.dso = mockItemWithoutAuthorAndDate.dspaceObject; - fixture.detectChanges(); - }); - - it('should not show the author paragraph', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); - expect(itemAuthorField).toBeNull(); - }); - }); - - describe('When the item has an issuedate', () => { + describe('When the item has a relationship type', () => { beforeEach(() => { - itemSearchResultListElementComponent.dso = mockItemWithAuthorAndDate.dspaceObject; + itemSearchResultListElementComponent.object = mockItemWithRelationshipType; fixture.detectChanges(); }); - it('should show the issuedate span', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-date')); - expect(itemAuthorField).not.toBeNull(); + it('should show the relationship type badge', () => { + const badge = fixture.debugElement.query(By.css('span.badge')); + console.log(itemSearchResultListElementComponent.dso); + expect(badge.nativeElement.textContent).toContain(type.toLowerCase()); }); }); - describe('When the item has no issuedate', () => { + describe('When the item has no relationship type', () => { beforeEach(() => { - itemSearchResultListElementComponent.dso = mockItemWithoutAuthorAndDate.dspaceObject; + itemSearchResultListElementComponent.object = mockItemWithoutRelationshipType; fixture.detectChanges(); }); - it('should not show the issuedate span', () => { - const dateField = fixture.debugElement.query(By.css('span.item-list-date')); - expect(dateField).toBeNull(); + it('should not show a badge', () => { + const badge = fixture.debugElement.query(By.css('span.badge')); + expect(badge).toBeNull(); }); }); }); diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts index a0cbc2469c6ced4b40bca3940e3c51f80300dec8..5bd3c8ff5a833420a68b1ff4399ba600a6695ee2 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts @@ -1,11 +1,14 @@ import { Component } from '@angular/core'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { Item } from '../../../../core/shared/item.model'; +import { focusBackground } from '../../../animations/focus'; +import { hasValue } from '../../../empty.util'; import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator'; -import { SearchResultListElementComponent } from '../search-result-list-element.component'; -import { Item } from '../../../../core/shared/item.model'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; -import { focusBackground } from '../../../animations/focus'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { SetViewMode } from '../../../view-mode'; +import { SearchResultListElementComponent } from '../search-result-list-element.component'; +import { ItemViewMode } from '../../../items/item-type-decorator'; @Component({ selector: 'ds-item-search-result-list-element', @@ -15,6 +18,8 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; }) -@renderElementsFor(ItemSearchResult, ViewMode.List) +@renderElementsFor(ItemSearchResult, SetViewMode.List) export class ItemSearchResultListElementComponent extends SearchResultListElementComponent<ItemSearchResult, Item> { + viewMode = ItemViewMode.Element; + } 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 525d39e798d723b2764b533621c2fa75e247522b..227d375f2a32a0d4b564c2108e8fcb76c77576c7 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,6 +3,7 @@ import { Observable } from 'rxjs'; import { SearchResult } from '../../../+search-page/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { hasValue } 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'; @@ -16,9 +17,11 @@ import { Metadata } from '../../../core/shared/metadata.utils'; export class SearchResultListElementComponent<T extends SearchResult<K>, K extends DSpaceObject> extends AbstractListableElementComponent<T> { dso: K; - public constructor(@Inject('objectElementProvider') public listable: ListableObject, private truncatableService: TruncatableService) { + public constructor(@Inject('objectElementProvider') public listable: ListableObject, protected truncatableService: TruncatableService) { super(listable); - this.dso = this.object.dspaceObject; + if (hasValue(this.object)) { + this.dso = this.object.indexableObject; + } } /** diff --git a/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts b/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts index e95def7cf7401a1081bae3a56df5fba1c20e23aa..17e6f0fd851f3c3c3f7e40666f4a1b31d5457ba7 100644 --- a/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts +++ b/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts @@ -1,8 +1,8 @@ import { Component, Injector, Input, OnInit } from '@angular/core'; +import { SetViewMode } from '../../view-mode'; import { GenericConstructor } from '../../../core/shared/generic-constructor'; import { rendersDSOType } from '../../object-collection/shared/dso-element-decorator' import { ListableObject } from '../../object-collection/shared/listable-object.model'; -import { ViewMode } from '../../../core/shared/view-mode.model'; @Component({ selector: 'ds-wrapper-list-element', @@ -11,19 +11,23 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; }) export class WrapperListElementComponent implements OnInit { @Input() object: ListableObject; + @Input() index: number; objectInjector: Injector; constructor(private injector: Injector) {} ngOnInit(): void { this.objectInjector = Injector.create({ - providers: [{ provide: 'objectElementProvider', useFactory: () => (this.object), deps:[] }], + providers: [ + { provide: 'objectElementProvider', useFactory: () => (this.object), deps:[] }, + { provide: 'indexElementProvider', useFactory: () => (this.index), deps:[] } + ], parent: this.injector }); } getListElement(): string { const f: GenericConstructor<ListableObject> = this.object.constructor as GenericConstructor<ListableObject>; - return rendersDSOType(f, ViewMode.List); + return rendersDSOType(f, SetViewMode.List); } } diff --git a/src/app/shared/pagination/pagination-component-options.model.ts b/src/app/shared/pagination/pagination-component-options.model.ts index 30ed2becd24f009101c9e5b4fee95d5df69df321..4f8a3c5c8cbe68cb58175cf5802ec6ecc3516c6f 100644 --- a/src/app/shared/pagination/pagination-component-options.model.ts +++ b/src/app/shared/pagination/pagination-component-options.model.ts @@ -12,11 +12,19 @@ export class PaginationComponentOptions extends NgbPaginationConfig { */ currentPage = 1; + /** + * Maximum number of pages to display. + */ + maxSize = 10; + /** * A number array that represents options for a context pagination limit. */ - pageSizeOptions: number[] = [5, 10, 20, 40, 60, 80, 100]; + pageSizeOptions: number[] = [1, 5, 10, 20, 40, 60, 80, 100]; + /** + * Number of items per page. + */ pageSize: number; } diff --git a/src/app/shared/pagination/pagination.component.html b/src/app/shared/pagination/pagination.component.html index 7336866e5c4ead38d3491a6ef0b6980370773fd8..22a58dd7fc94e645fe1f4aba4c4b37fd02bf7fca 100644 --- a/src/app/shared/pagination/pagination.component.html +++ b/src/app/shared/pagination/pagination.component.html @@ -1,7 +1,7 @@ <div *ngIf="currentPageState == undefined || currentPageState == currentPage"> <div class="pagination-masked clearfix top"> <div class="row"> - <div class="col pagination-info"> + <div *ngIf="!hidePaginationDetail" class="col-auto pagination-info"> <span class="align-middle hidden-xs-down">{{ 'pagination.showing.label' | translate }}</span> <span class="align-middle" *ngIf="collectionSize">{{ 'pagination.showing.detail' | translate:getShowingDetails(collectionSize)}}</span> </div> diff --git a/src/app/shared/pagination/pagination.component.spec.ts b/src/app/shared/pagination/pagination.component.spec.ts index 7b340b2eed1f27bdc605f80cedac5810cf07aeb5..dfbef9123ab3b63dd707f63a6301068cf3966b82 100644 --- a/src/app/shared/pagination/pagination.component.spec.ts +++ b/src/app/shared/pagination/pagination.component.spec.ts @@ -262,7 +262,7 @@ describe('Pagination component', () => { changePage(testFixture, 3); tick(); - expect(routerStub.navigate).toHaveBeenCalledWith([], { queryParams: { pageId: 'test', page: 3, pageSize: 10, sortDirection: 'ASC', sortField: 'dc.title' }, queryParamsHandling: 'merge' }); + expect(routerStub.navigate).toHaveBeenCalledWith([], { queryParams: { pageId: 'test', page: '3', pageSize: 10, sortDirection: 'ASC', sortField: 'dc.title' }, queryParamsHandling: 'merge' }); })); diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts index 0f2907a7bfc71a52b989318d459bf7f50cba5d5d..e5cb1f448af47db72303c315789f451440729543 100644 --- a/src/app/shared/pagination/pagination.component.ts +++ b/src/app/shared/pagination/pagination.component.ts @@ -8,16 +8,19 @@ import { Output, ViewEncapsulation } from '@angular/core' - import { ActivatedRoute, Router } from '@angular/router'; import { Subscription, Observable } from 'rxjs'; +import { isNumeric } from 'rxjs/internal-compatibility'; +import { isEqual, isObject, transform } from 'lodash'; + import { HostWindowService } from '../host-window.service'; import { HostWindowState } from '../host-window.reducer'; import { PaginationComponentOptions } from './pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { hasValue, isNotEmpty } from '../empty.util'; import { PageInfo } from '../../core/shared/page-info.model'; +import { difference } from '../object.util'; /** * The default pagination controls component. @@ -76,11 +79,16 @@ export class PaginationComponent implements OnDestroy, OnInit { @Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>(); /** - * An event fired when the sort field is changed. + * An event fired when the pagination is changed. * Event's payload equals to the newly selected sort field. */ @Output() paginationChange: EventEmitter<any> = new EventEmitter<any>(); + /** + * Option for hiding the pagination detail + */ + @Input() public hidePaginationDetail = false; + /** * Option for hiding the gear */ @@ -168,7 +176,7 @@ export class PaginationComponent implements OnDestroy, OnInit { this.subs.push(this.route.queryParams .subscribe((queryParams) => { if (this.isEmptyPaginationParams(queryParams)) { - this.initializeConfig(); + this.initializeConfig(queryParams); } else { this.currentQueryParams = queryParams; const fixedProperties = this.validateParams(queryParams); @@ -197,7 +205,7 @@ export class PaginationComponent implements OnDestroy, OnInit { /** * Initializes all default variables */ - private initializeConfig() { + private initializeConfig(queryParams: any = {}) { // Set initial values this.id = this.paginationOptions.id || null; this.pageSizeOptions = this.paginationOptions.pageSizeOptions; @@ -207,13 +215,13 @@ export class PaginationComponent implements OnDestroy, OnInit { this.sortDirection = this.sortOptions.direction; this.sortField = this.sortOptions.field; } - this.currentQueryParams = { + this.currentQueryParams = Object.assign({}, queryParams, { pageId: this.id, page: this.currentPage, pageSize: this.pageSize, sortDirection: this.sortDirection, sortField: this.sortField - }; + }); } /** @@ -235,7 +243,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The page being navigated to. */ public doPageChange(page: number) { - this.updateRoute({ page: page }); + this.updateRoute({ page: page.toString() }); } /** @@ -333,10 +341,23 @@ export class PaginationComponent implements OnDestroy, OnInit { * Method to update the route parameters */ private updateRoute(params: {}) { - this.router.navigate([], { - queryParams: Object.assign({}, this.currentQueryParams, params), - queryParamsHandling: 'merge' - }); + if (isNotEmpty(difference(params, this.currentQueryParams))) { + this.router.navigate([], { + queryParams: Object.assign({}, this.currentQueryParams, params), + queryParamsHandling: 'merge' + }); + } + } + + private difference(object, base) { + const changes = (o, b) => { + return transform(o, (result, value, key) => { + if (!isEqual(value, b[key]) && isNotEmpty(value)) { + result[key] = (isObject(value) && isObject(b[key])) ? changes(value, b[key]) : value; + } + }); + }; + return changes(object, base); } /** @@ -418,7 +439,7 @@ export class PaginationComponent implements OnDestroy, OnInit { */ private validatePage(page: any): number { let result = this.currentPage; - if (!isNaN(page)) { + if (isNumeric(page)) { result = +page; } return result; diff --git a/src/app/shared/roles/role.directive.ts b/src/app/shared/roles/role.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..d71e520e351cff1aeb12ee45f90be063af5f8313 --- /dev/null +++ b/src/app/shared/roles/role.directive.ts @@ -0,0 +1,114 @@ +import { + ChangeDetectorRef, + Directive, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + TemplateRef, + ViewContainerRef +} from '@angular/core'; + +import { combineLatest, Observable, Subscription } from 'rxjs'; +import { filter, first, map } from 'rxjs/operators'; + +import { hasValue } from '../empty.util'; +import { RoleService } from '../../core/roles/role.service'; +import { RoleType } from '../../core/roles/role-types'; + +@Directive({ + selector: '[dsShowOnlyForRole],[dsShowExceptForRole]' +}) +/** + * Structural Directive for showing or hiding a template based on current user role + */ +export class RoleDirective implements OnChanges, OnDestroy { + + /** + * The role or list of roles that can show template + */ + @Input() dsShowOnlyForRole: RoleType | RoleType[]; + + /** + * The role or list of roles that cannot show template + */ + @Input() dsShowExceptForRole: RoleType | RoleType[]; + + private subs: Subscription[] = []; + + constructor( + private roleService: RoleService, + private viewContainer: ViewContainerRef, + private changeDetector: ChangeDetectorRef, + private templateRef: TemplateRef<any> + ) { + } + + ngOnChanges(changes: SimpleChanges): void { + const onlyChanges = changes.dsShowOnlyForRole; + const exceptChanges = changes.dsShowExceptForRole; + this.hasRoles(this.dsShowOnlyForRole); + if (changes.dsShowOnlyForRole) { + this.validateOnly() + } else if (changes.dsShowExceptForRole) { + this.validateExcept() + } + } + + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + /** + * Show template in view container + */ + private showTemplateBlockInView(): void { + this.viewContainer.clear(); + if (!this.templateRef) { + return; + } + + this.viewContainer.createEmbeddedView(this.templateRef); + this.changeDetector.markForCheck(); + } + + /** + * Validate the list of roles that can show template + */ + private validateOnly(): void { + this.subs.push(this.hasRoles(this.dsShowOnlyForRole).pipe(filter((hasRole) => hasRole)) + .subscribe((hasRole) => { + this.showTemplateBlockInView(); + })); + } + + /** + * Validate the list of roles that cannot show template + */ + private validateExcept(): void { + this.subs.push(this.hasRoles(this.dsShowExceptForRole).pipe(filter((hasRole) => !hasRole)) + .subscribe((hasRole) => { + this.showTemplateBlockInView(); + })); + } + + /** + * Check if current user role is included in the specified role list + * + * @param roles + * The role or the list of roles + * @returns {Observable<boolean>} + * observable of true if current user role is included in the specified role list, observable of false otherwise + */ + private hasRoles(roles: RoleType | RoleType[]): Observable<boolean> { + const toValidate: RoleType[] = (Array.isArray(roles)) ? roles : [roles]; + const checks: Array<Observable<boolean>> = toValidate.map((role) => this.roleService.checkRole(role)); + + return combineLatest(checks).pipe( + map((permissions: boolean[]) => permissions.includes(true)), + first() + ) + } +} 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 b164abee1ff3a301110a8f167255d8ac8726f6b1..a60aeb8054d11b2e405e2921595f2f7ee1e0505a 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -8,6 +8,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { Community } from '../../core/shared/community.model'; import { TranslateModule } from '@ngx-translate/core'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { SearchService } from '../../+search-page/search-service/search.service'; describe('SearchFormComponent', () => { let comp: SearchFormComponent; @@ -18,6 +19,12 @@ describe('SearchFormComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [FormsModule, RouterTestingModule, TranslateModule.forRoot()], + providers: [ + { + provide: SearchService, + useValue: {} + } + ], declarations: [SearchFormComponent] }).compileComponents(); })); diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index 21a90daed471cd62dff7c1e90263e8423953ff04..10c3a3ede7bd803f68e0fd21c288470b9bfce268 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -3,6 +3,8 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Router } from '@angular/router'; import { hasValue, isNotEmpty } from '../empty.util'; import { QueryParamsHandling } from '@angular/router/src/config'; +import { MYDSPACE_ROUTE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchService } from '../../+search-page/search-service/search.service'; /** * This component renders a simple item page. @@ -25,6 +27,11 @@ export class SearchFormComponent { */ @Input() query: string; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * The currently selected scope object's UUID */ @@ -38,7 +45,7 @@ export class SearchFormComponent { */ @Input() scopes: DSpaceObject[]; - constructor(private router: Router) { + constructor(private router: Router, private searchService: SearchService) { } /** @@ -62,14 +69,9 @@ export class SearchFormComponent { * @param data Updated parameters */ updateSearch(data: any) { - const newUrl = hasValue(this.currentUrl) ? this.currentUrl : '/search'; - let handling: QueryParamsHandling = '' ; - if (this.currentUrl === '/search') { - handling = 'merge'; - } - this.router.navigate([newUrl], { + this.router.navigate(this.getSearchLinkParts(), { queryParams: Object.assign({}, { page: 1 }, data), - queryParamsHandling: handling + queryParamsHandling: 'merge' }); } @@ -80,4 +82,23 @@ export class SearchFormComponent { return isNotEmpty(object); } + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.searchService.getSearchLink(); + } + + /** + * @returns {string[]} The base path to the search page, or the current page when inPlaceSearch is true, split in separate pieces + */ + public getSearchLinkParts(): string[] { + if (this.inPlaceSearch) { + return []; + } + return this.getSearchLink().split('/'); + } } diff --git a/src/app/shared/services/route.service.spec.ts b/src/app/shared/services/route.service.spec.ts index 7d249cb12ae1252aedb9543b210b83e3f40d8af8..c9b3710ee6c74ae9eecd01a298779f32165ede03 100644 --- a/src/app/shared/services/route.service.spec.ts +++ b/src/app/shared/services/route.service.spec.ts @@ -29,6 +29,9 @@ describe('RouteService', () => { select: jasmine.createSpy('select') }); + const router = new MockRouter(); + router.setParams(convertToParamMap(paramObject)); + paramObject[paramName1] = paramValue1; paramObject[paramName2] = [paramValue2a, paramValue2b]; @@ -42,7 +45,7 @@ describe('RouteService', () => { queryParamMap: observableOf(convertToParamMap(paramObject)) }, }, - { provide: Router, useValue: new MockRouter() }, + { provide: Router, useValue: router }, { provide: Store, useValue: store }, ] }); diff --git a/src/app/shared/services/route.service.ts b/src/app/shared/services/route.service.ts index 48fc3ba0b004082fe1c6f2155c21baaa04eda9b9..a94b7e56daa8c24d760967d630c5118cdec5db4e 100644 --- a/src/app/shared/services/route.service.ts +++ b/src/app/shared/services/route.service.ts @@ -1,9 +1,16 @@ +import { distinctUntilChanged, filter, map, mergeMap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { ActivatedRoute, NavigationEnd, Params, Router, } from '@angular/router'; +import { + ActivatedRoute, + NavigationEnd, + Params, + Router, + RouterStateSnapshot, +} from '@angular/router'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { select, Store } from '@ngrx/store'; +import { isEqual } from 'lodash'; import { AppState } from '../../app.reducer'; import { AddUrlToHistoryAction } from '../history/history.actions'; @@ -14,8 +21,11 @@ import { historySelector } from '../history/selectors'; */ @Injectable() export class RouteService { + params: Observable<Params>; constructor(private route: ActivatedRoute, private router: Router, private store: Store<AppState>) { + this.subscribeToRouterParams(); + } /** @@ -23,7 +33,7 @@ export class RouteService { * @param paramName The name of the parameter to look for */ getQueryParameterValues(paramName: string): Observable<string[]> { - return this.route.queryParamMap.pipe( + return this.getQueryParamMap().pipe( map((params) => [...params.getAll(paramName)]), distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)) ); @@ -34,7 +44,7 @@ export class RouteService { * @param paramName The name of the parameter to look for */ getQueryParameterValue(paramName: string): Observable<string> { - return this.route.queryParamMap.pipe( + return this.getQueryParamMap().pipe( map((params) => params.get(paramName)), distinctUntilChanged() ); @@ -45,7 +55,7 @@ export class RouteService { * @param paramName The name of the parameter to look for */ hasQueryParam(paramName: string): Observable<boolean> { - return this.route.queryParamMap.pipe( + return this.getQueryParamMap().pipe( map((params) => params.has(paramName)), distinctUntilChanged() ); @@ -57,18 +67,26 @@ export class RouteService { * @param paramValue The value of the parameter to look for */ hasQueryParamWithValue(paramName: string, paramValue: string): Observable<boolean> { - return this.route.queryParamMap.pipe( + return this.getQueryParamMap().pipe( map((params) => params.getAll(paramName).indexOf(paramValue) > -1), distinctUntilChanged() ); } + getRouteParameterValue(paramName: string): Observable<string> { + return this.params.pipe(map((params) => params[paramName]),distinctUntilChanged(),); + } + + getRouteDataValue(datafield: string): Observable<any> { + return this.route.data.pipe(map((data) => data[datafield]),distinctUntilChanged(),); + } + /** * 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<Params> { - return this.route.queryParamMap.pipe( + return this.getQueryParamMap().pipe( map((qparams) => { const params = {}; qparams.keys @@ -82,6 +100,19 @@ export class RouteService { ); } + public getQueryParamMap(): Observable<any> { + return this.route.queryParamMap.pipe( + map((paramMap) => { + const snapshot: RouterStateSnapshot = this.router.routerState.snapshot; + // Due to an Angular bug, sometimes change of QueryParam is not detected so double checks with route snapshot + if (!isEqual(paramMap, snapshot.root.queryParamMap)) { + return snapshot.root.queryParamMap; + } else { + return paramMap; + } + })) + } + public saveRouting(): void { this.router.events .pipe(filter((event) => event instanceof NavigationEnd)) @@ -90,6 +121,18 @@ export class RouteService { }); } + subscribeToRouterParams() { + this.params = this.router.events.pipe( + mergeMap((event) => { + let active = this.route; + while (active.firstChild) { + active = active.firstChild; + } + return active.params; + }) + ); + } + public getHistory(): Observable<string[]> { return this.store.pipe(select(historySelector)); } @@ -99,5 +142,4 @@ export class RouteService { map((history: string[]) => history[history.length - 2] || '') ); } - } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 4b0f7834374f11e6844e845c8ed25b84ef0b6e26..4331b5e7e0ec6dd8e5c86c34dddbacc6aab8fd7d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -9,6 +9,16 @@ import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule import { TranslateModule } from '@ngx-translate/core'; import { NgxPaginationModule } from 'ngx-pagination'; +import { ItemTypeSwitcherComponent } from './items/switcher/item-type-switcher.component'; +import { OrgUnitMetadataListElementComponent } from './object-list/item-list-element/item-types/orgunit/orgunit-metadata-list-element.component'; +import { TypedItemSearchResultListElementComponent } from './object-list/item-list-element/item-types/typed-item-search-result-list-element.component'; +import { PublicationListElementComponent } from './object-list/item-list-element/item-types/publication/publication-list-element.component'; +import { OrgUnitListElementComponent } from './object-list/item-list-element/item-types/orgunit/orgunit-list-element.component'; +import { PersonListElementComponent } from './object-list/item-list-element/item-types/person/person-list-element.component'; +import { ProjectListElementComponent } from './object-list/item-list-element/item-types/project/project-list-element.component'; +import { JournalListElementComponent } from './object-list/item-list-element/item-types/journal/journal-list-element.component'; +import { JournalVolumeListElementComponent } from './object-list/item-list-element/item-types/journal-volume/journal-volume-list-element.component'; +import { JournalIssueListElementComponent } from './object-list/item-list-element/item-types/journal-issue/journal-issue-list-element.component'; import { FileUploadModule } from 'ng2-file-upload'; @@ -77,6 +87,20 @@ import { DsDatePickerComponent } from './form/builder/ds-dynamic-form-ui/models/ import { DsDynamicLookupComponent } from './form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component'; import { MockAdminGuard } from './mocks/mock-admin-guard.service'; import { AlertComponent } from './alert/alert.component'; +import { MyDSpaceResultListElementComponent } from './object-list/my-dspace-result-list-element/my-dspace-result-list-element.component'; +import { MyDSpaceResultDetailElementComponent } from './object-detail/my-dspace-result-detail-element/my-dspace-result-detail-element.component'; +import { ClaimedTaskActionsComponent } from './mydspace-actions/claimed-task/claimed-task-actions.component'; +import { PoolTaskActionsComponent } from './mydspace-actions/pool-task/pool-task-actions.component'; +import { ObjectDetailComponent } from './object-detail/object-detail.component'; +import { WrapperDetailElementComponent } from './object-detail/wrapper-detail-element/wrapper-detail-element.component'; +import { ItemDetailPreviewComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component'; +import { MyDSpaceItemStatusComponent } from './object-collection/shared/mydspace-item-status/my-dspace-item-status.component'; +import { WorkspaceitemActionsComponent } from './mydspace-actions/workspaceitem/workspaceitem-actions.component'; +import { WorkflowitemActionsComponent } from './mydspace-actions/workflowitem/workflowitem-actions.component'; +import { ItemSubmitterComponent } from './object-collection/shared/mydspace-item-submitter/item-submitter.component'; +import { ItemActionsComponent } from './mydspace-actions/item/item-actions.component'; +import { ClaimedTaskActionsApproveComponent } from './mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component'; +import { ClaimedTaskActionsRejectComponent } from './mydspace-actions/claimed-task/reject/claimed-task-actions-reject.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'; @@ -89,11 +113,16 @@ 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 { LangSwitchComponent } from './lang-switch/lang-switch.component'; +import { PlainTextMetadataListElementComponent } from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; +import { ItemMetadataListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; +import { TooltipModule } from 'ngx-bootstrap'; +import { PersonMetadataListElementComponent } from './object-list/item-list-element/item-types/person/person-metadata-list-element.component'; +import { MetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/metadata-representation-list-element.component'; 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'; @@ -110,7 +139,13 @@ import { ItemSearchResultListElementComponent } from './object-list/search-resul 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'; +import { ItemListPreviewComponent } from './object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component'; +import { MetadataFieldWrapperComponent } from '../+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import { MetadataValuesComponent } from '../+item-page/field-components/metadata-values/metadata-values.component'; +import { RoleDirective } from './roles/role.directive'; +import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component'; +import { ClaimedTaskActionsReturnToPoolComponent } from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component'; +import { ItemDetailPreviewFieldComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -135,6 +170,10 @@ const MODULES = [ MenuModule ]; +const ROOT_MODULES = [ + TooltipModule.forRoot() +]; + const PIPES = [ // put shared pipes here EnumKeysPipe, @@ -153,6 +192,7 @@ const COMPONENTS = [ // put shared components here AlertComponent, AuthNavMenuComponent, + UserMenuComponent, ChipsComponent, ComcolPageContentComponent, ComcolPageHeaderComponent, @@ -182,9 +222,11 @@ const COMPONENTS = [ LogOutComponent, NumberPickerComponent, ObjectListComponent, + ObjectDetailComponent, + ObjectGridComponent, AbstractListableElementComponent, WrapperListElementComponent, - ObjectGridComponent, + WrapperDetailElementComponent, WrapperGridElementComponent, ObjectCollectionComponent, PaginationComponent, @@ -193,6 +235,19 @@ const COMPONENTS = [ GridThumbnailComponent, UploaderComponent, WrapperListElementComponent, + ItemListPreviewComponent, + MyDSpaceItemStatusComponent, + ItemSubmitterComponent, + ItemDetailPreviewComponent, + ItemDetailPreviewFieldComponent, + ClaimedTaskActionsComponent, + ClaimedTaskActionsApproveComponent, + ClaimedTaskActionsRejectComponent, + ClaimedTaskActionsReturnToPoolComponent, + ItemActionsComponent, + PoolTaskActionsComponent, + WorkflowitemActionsComponent, + WorkspaceitemActionsComponent, ViewModeSwitchComponent, TruncatableComponent, TruncatablePartComponent, @@ -208,6 +263,9 @@ const COMPONENTS = [ CommunitySearchResultListElementComponent, CollectionSearchResultListElementComponent, ItemSearchResultListElementComponent, + TypedItemSearchResultListElementComponent, + ItemTypeSwitcherComponent, + BrowseByComponent ]; const ENTRY_COMPONENTS = [ @@ -215,6 +273,7 @@ const ENTRY_COMPONENTS = [ ItemListElementComponent, CollectionListElementComponent, CommunityListElementComponent, + MyDSpaceResultListElementComponent, SearchResultListElementComponent, CommunitySearchResultListElementComponent, CollectionSearchResultListElementComponent, @@ -223,7 +282,18 @@ const ENTRY_COMPONENTS = [ CollectionGridElementComponent, CommunityGridElementComponent, SearchResultGridElementComponent, + PublicationListElementComponent, + PersonListElementComponent, + PersonMetadataListElementComponent, + OrgUnitMetadataListElementComponent, + OrgUnitListElementComponent, + ProjectListElementComponent, + JournalListElementComponent, + JournalVolumeListElementComponent, + JournalIssueListElementComponent, BrowseEntryListElementComponent, + MyDSpaceResultDetailElementComponent, + SearchResultGridElementComponent, DsDynamicListComponent, DsDynamicLookupComponent, DsDynamicScrollableDropdownComponent, @@ -243,6 +313,15 @@ const ENTRY_COMPONENTS = [ EditCommunitySelectorComponent, EditCollectionSelectorComponent, EditItemSelectorComponent, + StartsWithTextComponent, + PlainTextMetadataListElementComponent, + ItemMetadataListElementComponent, + MetadataRepresentationListElementComponent +]; + +const SHARED_ITEM_PAGE_COMPONENTS = [ + MetadataFieldWrapperComponent, + MetadataValuesComponent, ]; const PROVIDERS = [ @@ -261,18 +340,21 @@ const DIRECTIVES = [ ClickOutsideDirective, AuthorityConfidenceStateDirective, InListValidator, - AutoFocusDirective + AutoFocusDirective, + RoleDirective ]; @NgModule({ imports: [ - ...MODULES + ...MODULES, + ...ROOT_MODULES ], declarations: [ ...PIPES, ...COMPONENTS, ...DIRECTIVES, ...ENTRY_COMPONENTS, + ...SHARED_ITEM_PAGE_COMPONENTS ], providers: [ ...PROVIDERS @@ -281,6 +363,7 @@ const DIRECTIVES = [ ...MODULES, ...PIPES, ...COMPONENTS, + ...SHARED_ITEM_PAGE_COMPONENTS, ...DIRECTIVES ], entryComponents: [ diff --git a/src/app/shared/testing/eperson-mock.ts b/src/app/shared/testing/eperson-mock.ts index ef27f4983d42ed526047c5d9a1b818838971bf61..c822fc15d68b10d56dccc27f77121b53ffbf7d43 100644 --- a/src/app/shared/testing/eperson-mock.ts +++ b/src/app/shared/testing/eperson-mock.ts @@ -13,26 +13,30 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(),{ id: 'testid', uuid: 'testid', type: 'eperson', - metadata: [ - { - key: 'dc.title', - language: null, - value: 'User Test' - }, - { - key: 'eperson.firstname', - language: null, - value: 'User' - }, - { - key: 'eperson.lastname', - language: null, - value: 'Test' - }, - { - key: 'eperson.language', - language: null, - value: 'en' - } - ] + metadata: { + 'dc.title': [ + { + language: null, + value: 'User Test' + } + ], + 'eperson.firstname': [ + { + language: null, + value: 'User' + } + ], + 'eperson.lastname': [ + { + language: null, + value: 'Test' + }, + ], + 'eperson.language': [ + { + language: null, + value: 'en' + }, + ] + } }); diff --git a/src/app/shared/testing/route-service-stub.ts b/src/app/shared/testing/route-service-stub.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3cdd9d8c67b56351ddbd0f18fa77a48167c446f --- /dev/null +++ b/src/app/shared/testing/route-service-stub.ts @@ -0,0 +1,32 @@ +import { of as observableOf } from 'rxjs/internal/observable/of'; + +export const routeServiceStub: any = { + /* tslint:disable:no-empty */ + hasQueryParamWithValue: (param: string, value: string) => { + }, + hasQueryParam: (param: string) => { + }, + removeQueryParameterValue: (param: string, value: string) => { + }, + addQueryParameterValue: (param: string, value: string) => { + }, + getQueryParameterValues: (param: string) => { + return observableOf({}); + }, + getQueryParamsWithPrefix: (param: string) => { + return observableOf({}); + }, + getQueryParamMap: () => { + return observableOf(new Map()) + }, + getQueryParameterValue: () => { + return observableOf({}) + }, + getRouteParameterValue: (param) => { + return observableOf('') + }, + getRouteDataValue: (param) => { + return observableOf({}) + } + /* tslint:enable:no-empty */ +}; diff --git a/src/app/shared/testing/router-stub.ts b/src/app/shared/testing/router-stub.ts index 31c09c41e30031da9f4f5943b811967c6d14e066..8630e16b2e83dbfbd250db5aa4d2dfbfda57a868 100644 --- a/src/app/shared/testing/router-stub.ts +++ b/src/app/shared/testing/router-stub.ts @@ -1,9 +1,11 @@ - +import { of as observableOf } from 'rxjs'; export class RouterStub { url: string; + routeReuseStrategy = {shouldReuseRoute: {}}; //noinspection TypeScriptUnresolvedFunction navigate = jasmine.createSpy('navigate'); parseUrl = jasmine.createSpy('parseUrl'); + events = observableOf({}); navigateByUrl(url): void { this.url = url; } diff --git a/src/app/shared/testing/search-configuration-service-stub.ts b/src/app/shared/testing/search-configuration-service-stub.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c9402afb1411f176479e387db1d9f37e8b4ddf9 --- /dev/null +++ b/src/app/shared/testing/search-configuration-service-stub.ts @@ -0,0 +1,19 @@ +import { BehaviorSubject, of as observableOf } from 'rxjs'; + +export class SearchConfigurationServiceStub { + + private searchOptions: BehaviorSubject<any> = new BehaviorSubject<any>({}); + private paginatedSearchOptions: BehaviorSubject<any> = new BehaviorSubject<any>({}); + + getCurrentFrontendFilters() { + return observableOf([]); + } + + getCurrentScope(a) { + return observableOf('test-id'); + } + + getCurrentConfiguration(a) { + return observableOf(a); + } +} diff --git a/src/app/shared/testing/search-service-stub.ts b/src/app/shared/testing/search-service-stub.ts index 2a46e42ef581c8cce3d363a475d455740cefaa08..d886604ef2ee309231a78788388be5902cff674f 100644 --- a/src/app/shared/testing/search-service-stub.ts +++ b/src/app/shared/testing/search-service-stub.ts @@ -1,22 +1,22 @@ import {of as observableOf, Observable , BehaviorSubject } from 'rxjs'; -import { ViewMode } from '../../core/shared/view-mode.model'; +import { SetViewMode } from '../view-mode'; export class SearchServiceStub { - private _viewMode: ViewMode; + private _viewMode: SetViewMode; private subject?: BehaviorSubject<any> = new BehaviorSubject(this.testViewMode); viewMode = this.subject.asObservable(); constructor(private searchLink: string = '/search') { - this.setViewMode(ViewMode.List); + this.setViewMode(SetViewMode.List); } - getViewMode(): Observable<ViewMode> { + getViewMode(): Observable<SetViewMode> { return this.viewMode; } - setViewMode(viewMode: ViewMode) { + setViewMode(viewMode: SetViewMode) { this.testViewMode = viewMode; } @@ -24,11 +24,11 @@ export class SearchServiceStub { return null; } - get testViewMode(): ViewMode { + get testViewMode(): SetViewMode { return this._viewMode; } - set testViewMode(viewMode: ViewMode) { + set testViewMode(viewMode: SetViewMode) { this._viewMode = viewMode; this.subject.next(viewMode); } diff --git a/src/app/shared/truncatable/truncatable.component.html b/src/app/shared/truncatable/truncatable.component.html index c03e93c2cec45ce3d96a701b43fe075c5c465f2e..b524e5e754ff5ce16258c92c73232c4d743e8627 100644 --- a/src/app/shared/truncatable/truncatable.component.html +++ b/src/app/shared/truncatable/truncatable.component.html @@ -1,3 +1,3 @@ -<div dsDragClick (actualClick)="toggle()" (mouseenter)="hoverExpand()" (mouseleave)="hoverCollapse"> +<div dsDragClick (actualClick)="toggle()" (mouseenter)="hoverExpand()" (mouseleave)="hoverCollapse()"> <ng-content></ng-content> -</div> \ No newline at end of file +</div> diff --git a/src/app/shared/uploader/uploader.component.html b/src/app/shared/uploader/uploader.component.html index 2ec1321afcd7ac7b27842b083087911696b90edb..5168404c83b2ef633ed870168490b688f96d836b 100644 --- a/src/app/shared/uploader/uploader.component.html +++ b/src/app/shared/uploader/uploader.component.html @@ -18,7 +18,7 @@ [uploader]="uploader" (fileOver)="fileOverBase($event)" class="well ds-base-drop-zone mt-1 mb-3 text-muted"> - <p class="text-center m-0 pt-2" [hidden]="uploader?.queue?.length !== 0"> + <p class="text-center m-0 p-0 d-flex justify-content-center align-items-center" *ngIf="uploader?.queue?.length === 0"> <span><i class="fas fa-cloud-upload" aria-hidden="true"></i> {{dropMsg | translate}} {{'uploader.or' | translate}} <label class="btn btn-link m-0 p-0"> <input class="d-none" type="file" ng2FileSelect [uploader]="uploader" multiple /> diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index 641901b4880a729fb192d540c2c251aa111875f9..ad52f4a93f9f6446a884c41c47f32a126a80cb07 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -1,5 +1,3 @@ - -import {of as observableOf, Observable } from 'rxjs'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -11,6 +9,7 @@ import { ViewEncapsulation, } from '@angular/core' +import { of as observableOf } from 'rxjs'; import { FileUploader } from 'ng2-file-upload'; import { uniqueId } from 'lodash'; import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; @@ -115,6 +114,9 @@ export class UploaderComponent { this.uploader.onAfterAddingFile = ((item) => { item.withCredentials = false; }); + if (isUndefined(this.onBeforeUpload)) { + this.onBeforeUpload = () => {return}; + } this.uploader.onBeforeUploadItem = () => { this.onBeforeUpload(); this.isOverDocumentDropZone = observableOf(false); 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 893047557834d725620e550df3712c97ac108671..905cf29baccbf327d19ac749bd43afe8db44f1ab 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 @@ -1,5 +1,6 @@ <div class="btn-group" data-toggle="buttons"> - <a routerLink="." + <a *ngIf="isToShow(viewModeEnum.List)" + routerLink="." [queryParams]="{view: 'list'}" queryParamsHandling="merge" (click)="switchViewTo(viewModeEnum.List)" @@ -8,13 +9,24 @@ class="btn btn-secondary"> <i class="fas fa-list" title="{{'search.view-switch.show-list' | translate}}"></i> </a> - <a routerLink="." + <a *ngIf="isToShow(viewModeEnum.Grid)" + routerLink="." [queryParams]="{view: 'grid'}" queryParamsHandling="merge" (click)="switchViewTo(viewModeEnum.Grid)" routerLinkActive="active" - [class.active]="currentMode !== viewModeEnum.List" + [class.active]="currentMode === viewModeEnum.Grid" class="btn btn-secondary"> <i class="fas fa-th-large" title="{{'search.view-switch.show-grid' | translate}}"></i> </a> -</div> \ No newline at end of file + <a *ngIf="isToShow(viewModeEnum.Detail)" + routerLink="." + [queryParams]="{view: 'detail'}" + queryParamsHandling="merge" + (click)="switchViewTo(viewModeEnum.Detail)" + routerLinkActive="active" + [class.active]="currentMode === viewModeEnum.Detail" + class="btn btn-secondary"> + <i class="far fa-square" title="{{'search.view-switch.show-detail' | translate}}"></i> + </a> +</div> diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index 1211b4bc888941a80573858e54b8d88b66da8d30..2fe405de3f2e4a4296d5261e5fe560e77f97cd9b 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -8,8 +8,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { SearchService } from '../../+search-page/search-service/search.service'; import { ViewModeSwitchComponent } from './view-mode-switch.component'; +import { SetViewMode } from '../view-mode'; import { SearchServiceStub } from '../testing/search-service-stub'; -import { ViewMode } from '../../core/shared/view-mode.model'; @Component({ template: '' }) class DummyComponent { } @@ -55,19 +55,19 @@ describe('ViewModeSwitchComponent', () => { }); it('should set list button as active when on list mode', fakeAsync(() => { - searchService.setViewMode(ViewMode.List); + searchService.setViewMode(SetViewMode.List); tick(); fixture.detectChanges(); - expect(comp.currentMode).toBe(ViewMode.List); + expect(comp.currentMode).toBe(SetViewMode.List); expect(listButton.classList).toContain('active'); expect(gridButton.classList).not.toContain('active'); })); it('should set grid button as active when on grid mode', fakeAsync(() => { - searchService.setViewMode(ViewMode.Grid); + searchService.setViewMode(SetViewMode.Grid); tick(); fixture.detectChanges(); - expect(comp.currentMode).toBe(ViewMode.Grid); + expect(comp.currentMode).toBe(SetViewMode.Grid); expect(listButton.classList).not.toContain('active'); expect(gridButton.classList).toContain('active'); })); diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.ts index 07c47435ffe14d7c075f490bbd7d61179bb7005d..dc355c6409c1c0caa60e2588f92197717416173b 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.ts @@ -1,7 +1,10 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; + import { Subscription } from 'rxjs'; -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { SearchService } from './../../+search-page/search-service/search.service'; + +import { SearchService } from '../../+search-page/search-service/search.service'; import { ViewMode } from '../../core/shared/view-mode.model'; +import { isEmpty } from '../empty.util'; /** * Component to switch between list and grid views. @@ -12,6 +15,13 @@ import { ViewMode } from '../../core/shared/view-mode.model'; templateUrl: './view-mode-switch.component.html' }) export class ViewModeSwitchComponent implements OnInit, OnDestroy { + @Input() viewModeList: ViewMode[]; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + currentMode: ViewMode = ViewMode.List; viewModeEnum = ViewMode; private sub: Subscription; @@ -20,13 +30,17 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { } ngOnInit(): void { + if (isEmpty(this.viewModeList)) { + this.viewModeList = [ViewMode.List, ViewMode.Grid]; + } + this.sub = this.searchService.getViewMode().subscribe((viewMode: ViewMode) => { this.currentMode = viewMode; }); } switchViewTo(viewMode: ViewMode) { - this.searchService.setViewMode(viewMode); + this.searchService.setViewMode(viewMode, this.getSearchLinkParts()); } ngOnDestroy() { @@ -34,4 +48,29 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { this.sub.unsubscribe(); } } + + isToShow(viewMode: ViewMode) { + return this.viewModeList && this.viewModeList.includes(viewMode); + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.searchService.getSearchLink(); + } + + /** + * @returns {string[]} The base path to the search page, or the current page when inPlaceSearch is true, split in separate pieces + */ + public getSearchLinkParts(): string[] { + if (this.searchService) { + return []; + } + return this.getSearchLink().split('/'); + } + } diff --git a/src/app/shared/view-mode.ts b/src/app/shared/view-mode.ts new file mode 100644 index 0000000000000000000000000000000000000000..826d467d6f228dbfed9ef60a027b479cad3384de --- /dev/null +++ b/src/app/shared/view-mode.ts @@ -0,0 +1,15 @@ +/** + * Enum used for defining the view-mode of a set of elements + * List Display the elements in a (vertical) list + * Grid Display the elements in a grid + */ +export enum SetViewMode { + List = 'list', + Grid = 'grid', + Detail = 'detail' +} + +/** + * ViewMode refers to either a SetViewMode or ElementViewMode + */ +export type ViewMode = SetViewMode; diff --git a/src/app/submission/form/footer/submission-form-footer.component.html b/src/app/submission/form/footer/submission-form-footer.component.html index 0d58456b24900d62fc1949bf91754cb53d807622..4d4892b6dc8a1704b491c10667c0969e0f701f9a 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.html +++ b/src/app/submission/form/footer/submission-form-footer.component.html @@ -17,7 +17,7 @@ <span>{{'submission.general.save' | translate}}</span> </button> <button type="button" - class="btn btn-info ml-1 mr-1" + class="btn btn-info" id="saveForLater" [disabled]="(processingSaveStatus | async)" (click)="saveLater($event)"> diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html index 36f22d932c63724f4d431d1a11b60313c496ed59..87fd0251f55c45eea112c7db1210acbd32aa72b7 100644 --- a/src/app/thumbnail/thumbnail.component.html +++ b/src/app/thumbnail/thumbnail.component.html @@ -1,4 +1,4 @@ <div class="thumbnail"> - <img *ngIf="thumbnail && thumbnail.content" [src]="thumbnail.content" (error)="errorHandler($event)" class="img-fluid"/> - <img *ngIf="!thumbnail || !thumbnail.content" [src]="holderSource | dsSafeUrl" class="img-fluid"/> + <img [src]="src | dsSafeUrl" (error)="errorHandler($event)" class="img-fluid"/> </div> + diff --git a/src/app/thumbnail/thumbnail.component.scss b/src/app/thumbnail/thumbnail.component.scss index da97dd7a62e610066229abae8f4c43a981b82780..938832770093130dadf3faa0c0fc92502c10ef8a 100644 --- a/src/app/thumbnail/thumbnail.component.scss +++ b/src/app/thumbnail/thumbnail.component.scss @@ -1 +1,4 @@ @import '../../styles/variables.scss'; +img { + max-width: 100%; +} diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index fc145d2397965f3c0c7aa9a8420664beae435627..f2be55d52cb5a32d370b75bada24e628b32af394 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -36,7 +36,7 @@ describe('ThumbnailComponent', () => { it('should display placeholder', () => { fixture.detectChanges(); const image: HTMLElement = de.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(comp.holderSource); + expect(image.getAttribute('src')).toBe(comp.defaultImage); }); }); diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts index cac1909b2b4cec7aa2a1f500f6b18258b3aeb264..9700e018218d8feff1b7ae2bdf76633872ce84c6 100644 --- a/src/app/thumbnail/thumbnail.component.ts +++ b/src/app/thumbnail/thumbnail.component.ts @@ -1,5 +1,6 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { Bitstream } from '../core/shared/bitstream.model'; +import { hasValue } from '../shared/empty.util'; /** * This component renders a given Bitstream as a thumbnail. @@ -12,19 +13,26 @@ import { Bitstream } from '../core/shared/bitstream.model'; styleUrls: ['./thumbnail.component.scss'], templateUrl: './thumbnail.component.html' }) -export class ThumbnailComponent { +export class ThumbnailComponent implements OnInit { @Input() thumbnail: Bitstream; - data: any = {}; - /** * The default 'holder.js' image */ - holderSource = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23EEEEEE%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E'; + @Input() defaultImage? = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23EEEEEE%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E'; + src: string; errorHandler(event) { - event.currentTarget.src = this.holderSource; + event.currentTarget.src = this.defaultImage; + } + + ngOnInit(): void { + if (hasValue(this.thumbnail) && this.thumbnail.content) { + this.src = this.thumbnail.content; + } else { + this.src = this.defaultImage + } } } diff --git a/src/main.browser.ts b/src/main.browser.ts index 8409a96485372b5ab9920795bafbb3a02a7c3dcf..264399a4b843d457211e587eb927ea5c3539752c 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -27,7 +27,7 @@ export function main() { addGoogleAnalytics(); - return platformBrowserDynamic().bootstrapModule(BrowserAppModule); + return platformBrowserDynamic().bootstrapModule(BrowserAppModule, {preserveWhitespaces:true}); } function addGoogleAnalytics() { diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 716002327a45bf17c6218e5308727cf2c15f49c6..be03d719c55e96acf4b739423ed607baac57b2c8 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -5,7 +5,7 @@ $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem $card-height-percentage:98%; $card-thumbnail-height:240px; $dropdown-menu-max-height: 200px; -$drop-zone-area-height: 42px; +$drop-zone-area-height: 44px; $drop-zone-area-z-index: 1025; $drop-zone-area-inner-z-index: 1021; $login-logo-height:72px; diff --git a/src/tsconfig.browser.json b/src/tsconfig.browser.json index f7140b9fe4d82fec7046682f4cb5455a183ad4bd..9eef264c94937a8b8f9bd611d0d4a53aeaf73484 100644 --- a/src/tsconfig.browser.json +++ b/src/tsconfig.browser.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "angularCompilerOptions": { - "entryModule": "./modules/app/browser-app.module#BrowserAppModule" + "entryModule": "./modules/app/browser-app.module#BrowserAppModule", + "preserveWhitespaces": true } } diff --git a/src/tsconfig.server.aot.json b/src/tsconfig.server.aot.json index de753624f39282f8a0216c0df89451cc01366b50..23f7854344c2c690cd495a117832f55f5c328fb1 100644 --- a/src/tsconfig.server.aot.json +++ b/src/tsconfig.server.aot.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.server.json", "angularCompilerOptions": { - "entryModule": "./modules/app/server-app.module#ServerAppModule" + "entryModule": "./modules/app/server-app.module#ServerAppModule", + "preserveWhitespaces": true }, "exclude": [] } diff --git a/src/tsconfig.server.json b/src/tsconfig.server.json index 480b685a2adec7ff01595aa2b39e9a2c0231804e..9881f1de5e4889a7cef8b5a553b5d91492d958ec 100644 --- a/src/tsconfig.server.json +++ b/src/tsconfig.server.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "angularCompilerOptions": { - "entryModule": "./modules/app/server-app.module#ServerAppModule" + "entryModule": "./modules/app/server-app.module#ServerAppModule", + "preserveWhitespaces": true } } diff --git a/webpack/run-webpack.js b/webpack/run-webpack.js new file mode 100644 index 0000000000000000000000000000000000000000..93f17b4619fbf5a91b40c9735295a3ce621c3560 --- /dev/null +++ b/webpack/run-webpack.js @@ -0,0 +1,13 @@ +const path = require('path'); +const child_process = require('child_process'); + +const heapSize = 4096; +const webpackPath = path.join('node_modules', 'webpack', 'bin', 'webpack.js'); + +const params = [ + '--max_old_space_size=' + heapSize, + webpackPath, + ...process.argv.slice(2) +]; + +child_process.spawn('node', params, { stdio:'inherit' });