diff --git a/README.md b/README.md
index 8f2320dbf3..cb2f41130f 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [h
Quick start
-----------
-**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`**
+**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) >= `v1.x`**
```bash
# clone the repo
@@ -65,7 +65,7 @@ Requirements
------------
- [Node.js](https://nodejs.org), [npm](https://www.npmjs.com/), and [yarn](https://yarnpkg.com)
-- Ensure you're running node >= `v5.x`, npm >= `v3.x` and yarn >= `v0.20.x`
+- Ensure you're running node >= `v8.x`, npm >= `v5.x` and yarn >= `v1.x`
If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS.
diff --git a/config/environment.default.js b/config/environment.default.js
index 4fccc92889..5f60335652 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 46eeb7be2f..cc687ea269 100644
--- a/package.json
+++ b/package.json
@@ -23,9 +23,9 @@
"prebuild": "yarn run clean:dist",
"prebuild:aot": "yarn run prebuild",
"prebuild:prod": "yarn run prebuild",
- "build": "webpack --progress --mode development",
- "build:aot": "webpack --env.aot --env.server --mode development && webpack --env.aot --env.client --mode development",
- "build:prod": "webpack --env.aot --env.server --mode production && webpack --env.aot --env.client --mode production",
+ "build": "node ./webpack/run-webpack.js --progress --mode development",
+ "build:aot": "node ./webpack/run-webpack.js --env.aot --env.server --mode development && node ./webpack/run-webpack.js --env.aot --env.client --mode development",
+ "build:prod": "node ./webpack/run-webpack.js --env.aot --env.server --mode production && node ./webpack/run-webpack.js --env.aot --env.client --mode production",
"postbuild:prod": "yarn run rollup",
"rollup": "rollup -c rollup.config.js",
"prestart": "yarn run build:prod",
@@ -40,7 +40,7 @@
"server": "node dist/server.js",
"server:watch": "nodemon dist/server.js",
"server:watch:debug": "nodemon --debug dist/server.js",
- "webpack:watch": "webpack -w --mode development",
+ "webpack:watch": "node ./webpack/run-webpack.js -w --mode development",
"watch": "yarn run build && npm-run-all -p webpack:watch server:watch",
"watch:debug": "yarn run build && npm-run-all -p webpack:watch server:watch:debug",
"predebug": "yarn run build",
@@ -108,7 +108,7 @@
"jwt-decode": "^2.2.0",
"methods": "1.1.2",
"moment": "^2.22.1",
- "morgan": "1.9.0",
+ "morgan": "^1.9.1",
"ng-mocks": "^6.2.1",
"ng2-file-upload": "1.2.1",
"ng2-nouislider": "^1.7.11",
diff --git a/resources/i18n/en.json b/resources/i18n/en.json
index 3b6062fa91..b9a27e7a84 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 not suitable for inclusion in the collection, select \"Reject\". You will then be asked to enter a message indicating why the item is unsuitable, and whether the submitter should change something and resubmit.",
+ "return": "Return to pool",
+ "return_help": "Return the task to the pool so that another user may perform the task."
+
+ },
+ "pool": {
+ "claim": "Claim",
+ "claim_help": "Assign this task to yourself.",
+ "show-detail": "Show detail",
+ "hide-detail": "Hide detail"
+ }
+ }
}
},
"uploader": {
diff --git a/resources/images/orgunit-placeholder.svg b/resources/images/orgunit-placeholder.svg
new file mode 100644
index 0000000000..1dae3d607e
--- /dev/null
+++ b/resources/images/orgunit-placeholder.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/resources/images/person-placeholder.svg b/resources/images/person-placeholder.svg
new file mode 100644
index 0000000000..bbe84ec845
--- /dev/null
+++ b/resources/images/person-placeholder.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/resources/images/project-placeholder.svg b/resources/images/project-placeholder.svg
new file mode 100644
index 0000000000..75ce1003fe
--- /dev/null
+++ b/resources/images/project-placeholder.svg
@@ -0,0 +1,118 @@
+
+
+
+
diff --git a/src/app/+admin/admin-registries/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 4364b0234a..c6402c1f3b 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 f148627297..3ad1bd4272 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 4921be77e2..112560de16 100644
--- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts
+++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts
@@ -18,8 +18,8 @@ import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorat
templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor]
-
})
+
@rendersSectionForMenu(MenuID.ADMIN, true)
export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit {
/**
diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html
index 6265b223d8..91239de17c 100644
--- a/src/app/+collection-page/collection-page.component.html
+++ b/src/app/+collection-page/collection-page.component.html
@@ -1,58 +1,62 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{'collection.page.browse.recent.head' | translate}}
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
{{'collection.page.browse.recent.head' | translate}}
-
-
-
-
-
-
-
diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts
index 7c4f2b92ac..41afbf2115 100644
--- a/src/app/+collection-page/collection-page.component.ts
+++ b/src/app/+collection-page/collection-page.component.ts
@@ -1,6 +1,9 @@
-import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
-import { Observable, Subscription } from 'rxjs';
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { BehaviorSubject, of as observableOf, Observable, Subject } from 'rxjs';
+import { filter, flatMap, map, startWith, switchMap, take, tap } from 'rxjs/operators';
+import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
+import { SearchService } from '../+search-page/search-service/search.service';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CollectionDataService } from '../core/data/collection-data.service';
import { PaginatedList } from '../core/data/paginated-list';
@@ -10,16 +13,17 @@ import { MetadataService } from '../core/metadata/metadata.service';
import { Bitstream } from '../core/shared/bitstream.model';
import { Collection } from '../core/shared/collection.model';
+import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
import { Item } from '../core/shared/item.model';
+import {
+ getSucceededRemoteData,
+ redirectToPageNotFoundOn404,
+ toDSpaceObjectListRD
+} from '../core/shared/operators';
import { fadeIn, fadeInOut } from '../shared/animations/fade';
-import { hasValue, isNotEmpty } from '../shared/empty.util';
+import { hasNoValue, hasValue, isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
-import { filter, flatMap, map, tap } from 'rxjs/operators';
-import { SearchService } from '../+search-page/search-service/search.service';
-import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
-import { toDSpaceObjectListRD } from '../core/shared/operators';
-import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
@Component({
selector: 'ds-collection-page',
@@ -31,20 +35,23 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
fadeInOut
]
})
-export class CollectionPageComponent implements OnInit, OnDestroy {
+export class CollectionPageComponent implements OnInit {
collectionRD$: Observable
>;
itemRD$: Observable>>;
logoRD$: Observable>;
paginationConfig: PaginationComponentOptions;
sortConfig: SortOptions;
- private subs: Subscription[] = [];
- private collectionId: string;
+ private paginationChanges$: Subject<{
+ paginationConfig: PaginationComponentOptions,
+ sortConfig: SortOptions
+ }>;
constructor(
private collectionDataService: CollectionDataService,
private searchService: SearchService,
private metadata: MetadataService,
- private route: ActivatedRoute
+ private route: ActivatedRoute,
+ private router: Router
) {
this.paginationConfig = new PaginationComponentOptions();
this.paginationConfig.id = 'collection-page-pagination';
@@ -55,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),
+ redirectToPageNotFoundOn404(this.router),
+ take(1)
);
this.logoRD$ = this.collectionRD$.pipe(
map((rd: RemoteData) => rd.payload),
filter((collection: Collection) => hasValue(collection)),
flatMap((collection: Collection) => collection.logo)
);
- this.subs.push(
- this.route.queryParams.subscribe((params) => {
- this.metadata.processRemoteData(this.collectionRD$);
- const page = +params.page || this.paginationConfig.currentPage;
- const pageSize = +params.pageSize || this.paginationConfig.pageSize;
- const pagination = Object.assign({},
- this.paginationConfig,
- { currentPage: page, pageSize: pageSize }
- );
- this.updatePage({
- pagination: pagination,
- sort: this.sortConfig
- });
- }));
- }
+ this.paginationChanges$ = new BehaviorSubject({
+ paginationConfig: this.paginationConfig,
+ sortConfig: this.sortConfig
+ });
- updatePage(searchOptions) {
- this.itemRD$ = this.searchService.search(
- new PaginatedSearchOptions({
- scope: this.collectionId,
- pagination: searchOptions.pagination,
- sort: searchOptions.sort,
- dsoType: DSpaceObjectType.ITEM
- })).pipe(toDSpaceObjectListRD()) as Observable>>;
- }
+ this.itemRD$ = this.paginationChanges$.pipe(
+ switchMap((dto) => this.collectionRD$.pipe(
+ getSucceededRemoteData(),
+ map((rd) => rd.payload.id),
+ switchMap((id: string) => {
+ return this.searchService.search(
+ new PaginatedSearchOptions({
+ scope: id,
+ pagination: dto.paginationConfig,
+ sort: dto.sortConfig,
+ dsoType: DSpaceObjectType.ITEM
+ })).pipe(toDSpaceObjectListRD()) as Observable>>
+ }),
+ startWith(undefined) // Make sure switching pages shows loading component
+ )
+ )
+ );
- ngOnDestroy(): void {
- this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
+ this.route.queryParams.pipe(take(1)).subscribe((params) => {
+ this.metadata.processRemoteData(this.collectionRD$);
+ this.onPaginationChange(params);
+ })
}
isNotEmpty(object: any) {
@@ -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 f0e4138d2d..bdeffa34f3 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 d4835e2e14..8c6e3ad8a6 100644
--- a/src/app/+collection-page/collection-page.resolver.ts
+++ b/src/app/+collection-page/collection-page.resolver.ts
@@ -4,7 +4,8 @@ import { Collection } from '../core/shared/collection.model';
import { Observable } from 'rxjs';
import { CollectionDataService } from '../core/data/collection-data.service';
import { RemoteData } from '../core/data/remote-data';
-import { getSucceededRemoteData } from '../core/shared/operators';
+import { find } from 'rxjs/operators';
+import { hasValue } from '../shared/empty.util';
/**
* This class represents a resolver that requests a specific collection before the route is activated
@@ -18,11 +19,12 @@ export class CollectionPageResolver implements Resolve> {
* Method for resolving a collection based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
- * @returns Observable<> Emits the found collection based on the parameters in the current route
+ * @returns Observable<> Emits the found collection based on the parameters in the current route,
+ * or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> {
return this.collectionService.findById(route.params.id).pipe(
- getSucceededRemoteData()
+ find((RD) => hasValue(RD.error) || RD.hasSucceeded),
);
}
}
diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts
index a3978a5e43..ba70bd26c6 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 2035faf988..f337d70250 100644
--- a/src/app/+community-page/community-page.component.ts
+++ b/src/app/+community-page/community-page.component.ts
@@ -1,6 +1,6 @@
import { mergeMap, filter, map } 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),
+ redirectToPageNotFoundOn404(this.router)
+ );
this.logoRD$ = this.communityRD$.pipe(
map((rd: RemoteData) => 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 a32fe78bc5..ffa66fa123 100644
--- a/src/app/+community-page/community-page.resolver.ts
+++ b/src/app/+community-page/community-page.resolver.ts
@@ -2,9 +2,10 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { RemoteData } from '../core/data/remote-data';
-import { getSucceededRemoteData } from '../core/shared/operators';
import { Community } from '../core/shared/community.model';
import { CommunityDataService } from '../core/data/community-data.service';
+import { find } from 'rxjs/operators';
+import { hasValue } from '../shared/empty.util';
/**
* This class represents a resolver that requests a specific community before the route is activated
@@ -18,11 +19,12 @@ export class CommunityPageResolver implements Resolve> {
* Method for resolving a community based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
- * @returns Observable<> Emits the found community based on the parameters in the current route
+ * @returns Observable<> Emits the found community based on the parameters in the current route,
+ * or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> {
return this.communityService.findById(route.params.id).pipe(
- getSucceededRemoteData()
+ find((RD) => hasValue(RD.error) || RD.hasSucceeded)
);
}
}
diff --git a/src/app/+home-page/home-news/home-news.component.html b/src/app/+home-page/home-news/home-news.component.html
index 47ceaac90f..28e10c5804 100644
--- a/src/app/+home-page/home-news/home-news.component.html
+++ b/src/app/+home-page/home-news/home-news.component.html
@@ -3,7 +3,7 @@
-
Welcome to the DSpace 7 Preview Release
+
Welcome to the DSpace 7 Preview
DSpace is the world leading open source repository platform that enables organisations to:
diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html
index 6a3e20ca9d..39ba479033 100644
--- a/src/app/+home-page/home-page.component.html
+++ b/src/app/+home-page/home-page.component.html
@@ -1,5 +1,5 @@
-
+
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts
index 4ea47f08e7..eafc04ae0b 100644
--- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts
+++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts
@@ -1,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 bbe6d8d95b..c791cec600 100644
--- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html
+++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html
@@ -1,4 +1,4 @@
-
+
diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts
index cce54edf64..d7e1b80c76 100644
--- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts
+++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts
@@ -1,18 +1,41 @@
-import { ComponentFixture, TestBed, async } from '@angular/core/testing';
-import { By } from '@angular/platform-browser';
-import { Component, DebugElement } from '@angular/core';
+import { Component } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component';
+/* tslint:disable:max-classes-per-file */
@Component({
- selector: 'ds-component-with-content',
+ selector: 'ds-component-without-content',
template: '
\n' +
- ' \n' +
- ' \n' +
- '
\n' +
''
})
-class ContentComponent {}
+class NoContentComponent {}
+
+@Component({
+ selector: 'ds-component-with-empty-spans',
+ template: '
\n' +
+ ' \n' +
+ ' \n' +
+ ''
+})
+class SpanContentComponent {}
+
+@Component({
+ selector: 'ds-component-with-text',
+ template: '
\n' +
+ ' The quick brown fox jumps over the lazy dog\n' +
+ ''
+})
+class TextContentComponent {}
+
+@Component({
+ selector: 'ds-component-with-image',
+ template: '
\n' +
+ '
\n' +
+ ''
+})
+class ImgContentComponent {}
+/* tslint:enable:max-classes-per-file */
describe('MetadataFieldWrapperComponent', () => {
let component: MetadataFieldWrapperComponent;
@@ -20,7 +43,7 @@ describe('MetadataFieldWrapperComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
- declarations: [MetadataFieldWrapperComponent, ContentComponent]
+ declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent]
}).compileComponents();
}));
@@ -30,23 +53,21 @@ describe('MetadataFieldWrapperComponent', () => {
});
const wrapperSelector = '.simple-view-element';
- const labelSelector = '.simple-view-element-header';
- const contentSelector = '.my-content';
it('should create', () => {
expect(component).toBeDefined();
});
it('should not show the component when there is no content', () => {
- component.label = 'test label';
- fixture.detectChanges();
- const parentNative = fixture.nativeElement;
+ const parentFixture = TestBed.createComponent(NoContentComponent);
+ parentFixture.detectChanges();
+ const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector);
expect(nativeWrapper.classList.contains('d-none')).toBe(true);
});
- it('should not show the component when there is DOM content but no text', () => {
- const parentFixture = TestBed.createComponent(ContentComponent);
+ it('should not show the component when there is DOM content but not text or an image', () => {
+ const parentFixture = TestBed.createComponent(SpanContentComponent);
parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector);
@@ -54,11 +75,18 @@ describe('MetadataFieldWrapperComponent', () => {
});
it('should show the component when there is text content', () => {
- const parentFixture = TestBed.createComponent(ContentComponent);
+ const parentFixture = TestBed.createComponent(TextContentComponent);
+ parentFixture.detectChanges();
+ const parentNative = parentFixture.nativeElement;
+ const nativeWrapper = parentNative.querySelector(wrapperSelector);
+ parentFixture.detectChanges();
+ expect(nativeWrapper.classList.contains('d-none')).toBe(false);
+ });
+
+ it('should show the component when there is img content', () => {
+ const parentFixture = TestBed.createComponent(ImgContentComponent);
parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement;
- const nativeContent = parentNative.querySelector(contentSelector);
- nativeContent.textContent = 'lorem ipsum';
const nativeWrapper = parentNative.querySelector(wrapperSelector);
parentFixture.detectChanges();
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts
index 8c80384732..8af108cceb 100644
--- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts
+++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts
@@ -1,4 +1,5 @@
import { Component, Input } from '@angular/core';
+import { hasNoValue } from '../../../shared/empty.util';
/**
* This component renders any content inside this wrapper.
@@ -11,6 +12,15 @@ import { Component, Input } from '@angular/core';
})
export class MetadataFieldWrapperComponent {
+ /**
+ * The label (title) for the content
+ */
@Input() label: string;
+ /**
+ * Make hasNoValue() available in the template
+ */
+ hasNoValue(o: any): boolean {
+ return hasNoValue(o);
+ }
}
diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts
new file mode 100644
index 0000000000..2b32ece3c3
--- /dev/null
+++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts
@@ -0,0 +1,97 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader';
+import { By } from '@angular/platform-browser';
+import { MetadataUriValuesComponent } from './metadata-uri-values.component';
+import { isNotEmpty } from '../../../shared/empty.util';
+import { MetadataValue } from '../../../core/shared/metadata.models';
+
+let comp: MetadataUriValuesComponent;
+let fixture: ComponentFixture
;
+
+const mockMetadata = [
+ {
+ language: 'en_US',
+ value: 'http://fakelink.org'
+ },
+ {
+ language: 'en_US',
+ value: 'http://another.fakelink.org'
+ }
+] as MetadataValue[];
+const mockSeperator = '
';
+const mockLabel = 'fake.message';
+const mockLinkText = 'fake link text';
+
+describe('MetadataUriValuesComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [MetadataUriValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(MetadataUriValuesComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(MetadataUriValuesComponent);
+ comp = fixture.componentInstance;
+ comp.mdValues = mockMetadata;
+ comp.separator = mockSeperator;
+ comp.label = mockLabel;
+ fixture.detectChanges();
+ }));
+
+ it('should display all metadata values', () => {
+ const innerHTML = fixture.nativeElement.innerHTML;
+ for (const metadatum of mockMetadata) {
+ expect(innerHTML).toContain(metadatum.value);
+ }
+ });
+
+ it('should contain the correct hrefs', () => {
+ const links = fixture.debugElement.queryAll(By.css('a'));
+ for (const metadatum of mockMetadata) {
+ expect(containsHref(links, metadatum.value)).toBeTruthy();
+ }
+ });
+
+ it('should contain separators equal to the amount of metadata values minus one', () => {
+ const separators = fixture.debugElement.queryAll(By.css('a span'));
+ expect(separators.length).toBe(mockMetadata.length - 1);
+ });
+
+ describe('when linktext is defined', () => {
+
+ beforeEach(() => {
+ comp.linktext = mockLinkText;
+ fixture.detectChanges();
+ });
+
+ it('should replace the metadata value with the linktext', () => {
+ const link = fixture.debugElement.query(By.css('a'));
+ expect(link.nativeElement.textContent).toContain(mockLinkText);
+ });
+
+ });
+
+});
+
+function containsHref(links: DebugElement[], href: string): boolean {
+ for (const link of links) {
+ const hrefAtt = link.properties.href;
+ if (isNotEmpty(hrefAtt)) {
+ if (hrefAtt === href) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts
index 67684d44af..e070eccf2d 100644
--- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts
+++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts
@@ -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 0000000000..cad2edb98a
--- /dev/null
+++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts
@@ -0,0 +1,65 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader';
+import { MetadataValuesComponent } from './metadata-values.component';
+import { By } from '@angular/platform-browser';
+import { MetadataValue } from '../../../core/shared/metadata.models';
+
+let comp: MetadataValuesComponent;
+let fixture: ComponentFixture;
+
+const mockMetadata = [
+ {
+ language: 'en_US',
+ value: '1234'
+ },
+ {
+ language: 'en_US',
+ value: 'a publisher'
+ },
+ {
+ language: 'en_US',
+ value: 'desc'
+ }] as MetadataValue[];
+const mockSeperator = '
';
+const mockLabel = 'fake.message';
+
+describe('MetadataValuesComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [MetadataValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(MetadataValuesComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(MetadataValuesComponent);
+ comp = fixture.componentInstance;
+ comp.mdValues = mockMetadata;
+ comp.separator = mockSeperator;
+ comp.label = mockLabel;
+ fixture.detectChanges();
+ }));
+
+ it('should display all metadata values', () => {
+ const innerHTML = fixture.nativeElement.innerHTML;
+ for (const metadatum of mockMetadata) {
+ expect(innerHTML).toContain(metadatum.value);
+ }
+ });
+
+ it('should contain separators equal to the amount of metadata values minus one', () => {
+ const separators = fixture.debugElement.queryAll(By.css('span>span'));
+ expect(separators.length).toBe(mockMetadata.length - 1);
+ });
+
+});
diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts
index abcd90848d..142b08b360 100644
--- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts
+++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts
@@ -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 0000000000..15dd001964
--- /dev/null
+++ b/src/app/+item-page/full/full-item-page.component.spec.ts
@@ -0,0 +1,78 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ItemDataService } from '../../core/data/item-data.service';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { TruncatePipe } from '../../shared/utils/truncate.pipe';
+import { FullItemPageComponent } from './full-item-page.component';
+import { MetadataService } from '../../core/metadata/metadata.service';
+import { ActivatedRoute } from '@angular/router';
+import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
+import { VarDirective } from '../../shared/utils/var.directive';
+import { RouterTestingModule } from '@angular/router/testing';
+import { Item } from '../../core/shared/item.model';
+import { PageInfo } from '../../core/shared/page-info.model';
+import { PaginatedList } from '../../core/data/paginated-list';
+import { RemoteData } from '../../core/data/remote-data';
+import { of as observableOf } from 'rxjs';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { By } from '@angular/platform-browser';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: {
+ 'dc.title': [
+ {
+ language: 'en_US',
+ value: 'test item'
+ }
+ ]
+ }
+});
+const routeStub = Object.assign(new ActivatedRouteStub(), {
+ data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) })
+});
+const metadataServiceStub = {
+ /* tslint:disable:no-empty */
+ processRemoteData: () => {}
+ /* tslint:enable:no-empty */
+};
+
+describe('FullItemPageComponent', () => {
+ let comp: FullItemPageComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ }), RouterTestingModule.withRoutes([]), BrowserAnimationsModule],
+ declarations: [FullItemPageComponent, TruncatePipe, VarDirective],
+ providers: [
+ {provide: ActivatedRoute, useValue: routeStub},
+ {provide: ItemDataService, useValue: {}},
+ {provide: MetadataService, useValue: metadataServiceStub}
+ ],
+
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(FullItemPageComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(FullItemPageComponent);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ it('should display the item\'s metadata', () => {
+ const table = fixture.debugElement.query(By.css('table'));
+ for (const metadatum of mockItem.allMetadata([])) {
+ expect(table.nativeElement.innerHTML).toContain(metadatum.value);
+ }
+ })
+});
diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts
index 6e19a50864..b2a42b7c6f 100644
--- a/src/app/+item-page/full/full-item-page.component.ts
+++ b/src/app/+item-page/full/full-item-page.component.ts
@@ -1,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>;
+ itemRD$: BehaviorSubject>;
metadata$: Observable;
- constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) {
- super(route, items, metadataService);
+ constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService) {
+ super(route, router, items, metadataService);
}
/*** AoT inheritance fix, will hopefully be resolved in the near future **/
diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts
index c60f9d3583..123e3ea143 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 c0ee6a84ee..4b7ef23b69 100644
--- a/src/app/+item-page/item-page.resolver.ts
+++ b/src/app/+item-page/item-page.resolver.ts
@@ -2,9 +2,10 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { RemoteData } from '../core/data/remote-data';
-import { getSucceededRemoteData } from '../core/shared/operators';
import { ItemDataService } from '../core/data/item-data.service';
import { Item } from '../core/shared/item.model';
+import { hasValue } from '../shared/empty.util';
+import { find } from 'rxjs/operators';
/**
* This class represents a resolver that requests a specific item before the route is activated
@@ -18,11 +19,13 @@ export class ItemPageResolver implements Resolve> {
* Method for resolving an item based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
- * @returns Observable<> Emits the found item based on the parameters in the current route
+ * @returns Observable<> Emits the found item based on the parameters in the current route,
+ * or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> {
- return this.itemService.findById(route.params.id).pipe(
- getSucceededRemoteData()
- );
+ return this.itemService.findById(route.params.id)
+ .pipe(
+ find((RD) => hasValue(RD.error) || RD.hasSucceeded),
+ );
}
}
diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts
new file mode 100644
index 0000000000..9461ee0950
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts
@@ -0,0 +1,41 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ItemPageAbstractFieldComponent } from './item-page-abstract-field.component';
+import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
+import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
+import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
+
+let comp: ItemPageAbstractFieldComponent;
+let fixture: ComponentFixture;
+
+const mockField = 'dc.description.abstract';
+const mockValue = 'test value';
+
+describe('ItemPageAbstractFieldComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [ItemPageAbstractFieldComponent, MetadataValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemPageAbstractFieldComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemPageAbstractFieldComponent);
+ comp = fixture.componentInstance;
+ comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
+ fixture.detectChanges();
+ }));
+
+ it('should display display the correct metadata value', () => {
+ expect(fixture.nativeElement.innerHTML).toContain(mockValue);
+ });
+});
diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts
index a8cc309ab6..00984d6592 100644
--- a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts
+++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts
@@ -1,22 +1,39 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model';
-import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component';
+import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({
selector: 'ds-item-page-abstract-field',
- templateUrl: './../item-page-specific-field.component.html'
+ templateUrl: '../item-page-field.component.html'
})
-export class ItemPageAbstractFieldComponent extends ItemPageSpecificFieldComponent {
+/**
+ * This component is used for displaying the abstract (dc.description.abstract) of an item
+ */
+export class ItemPageAbstractFieldComponent extends ItemPageFieldComponent {
+ /**
+ * The item to display metadata for
+ */
@Input() item: Item;
+ /**
+ * Separator string between multiple values of the metadata fields defined
+ * @type {string}
+ */
separator: string;
+ /**
+ * Fields (schema.element.qualifier) used to render their values.
+ * In this component, we want to display values for metadata 'dc.description.abstract'
+ */
fields: string[] = [
'dc.description.abstract'
];
+ /**
+ * Label i18n key for the rendered metadata
+ */
label = 'item.page.abstract';
}
diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts
new file mode 100644
index 0000000000..d865caff8a
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts
@@ -0,0 +1,45 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
+import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
+import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
+import { ItemPageAuthorFieldComponent } from './item-page-author-field.component';
+
+let comp: ItemPageAuthorFieldComponent;
+let fixture: ComponentFixture;
+
+const mockFields = ['dc.contributor.author', 'dc.creator', 'dc.contributor'];
+const mockValue = 'test value';
+
+describe('ItemPageAuthorFieldComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemPageAuthorFieldComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ for (const field of mockFields) {
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemPageAuthorFieldComponent);
+ comp = fixture.componentInstance;
+ comp.item = mockItemWithMetadataFieldAndValue(field, mockValue);
+ fixture.detectChanges();
+ }));
+
+ describe(`when the item contains metadata for ${field}`, () => {
+ it('should display display the correct metadata value', () => {
+ expect(fixture.nativeElement.innerHTML).toContain(mockValue);
+ });
+ });
+ }
+});
diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts
index e84a52d1b9..51941d2cc8 100644
--- a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts
+++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts
@@ -1,24 +1,41 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model';
-import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component';
+import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({
selector: 'ds-item-page-author-field',
- templateUrl: './../item-page-specific-field.component.html'
+ templateUrl: '../item-page-field.component.html'
})
-export class ItemPageAuthorFieldComponent extends ItemPageSpecificFieldComponent {
+/**
+ * This component is used for displaying the author (dc.contributor.author, dc.creator and dc.contributor) metadata of an item
+ */
+export class ItemPageAuthorFieldComponent extends ItemPageFieldComponent {
+ /**
+ * The item to display metadata for
+ */
@Input() item: Item;
+ /**
+ * Separator string between multiple values of the metadata fields defined
+ * @type {string}
+ */
separator: string;
+ /**
+ * Fields (schema.element.qualifier) used to render their values.
+ * In this component, we want to display values for metadata 'dc.contributor.author', 'dc.creator' and 'dc.contributor'
+ */
fields: string[] = [
'dc.contributor.author',
'dc.creator',
'dc.contributor'
];
+ /**
+ * Label i18n key for the rendered metadata
+ */
label = 'item.page.author';
}
diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts
new file mode 100644
index 0000000000..2adada582b
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts
@@ -0,0 +1,41 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
+import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
+import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
+import { ItemPageDateFieldComponent } from './item-page-date-field.component';
+
+let comp: ItemPageDateFieldComponent;
+let fixture: ComponentFixture;
+
+const mockField = 'dc.date.issued';
+const mockValue = 'test value';
+
+describe('ItemPageDateFieldComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [ItemPageDateFieldComponent, MetadataValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemPageDateFieldComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemPageDateFieldComponent);
+ comp = fixture.componentInstance;
+ comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
+ fixture.detectChanges();
+ }));
+
+ it('should display display the correct metadata value', () => {
+ expect(fixture.nativeElement.innerHTML).toContain(mockValue);
+ });
+});
diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts
index 6950944f87..5a7d56b7da 100644
--- a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts
+++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts
@@ -1,22 +1,39 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model';
-import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component';
+import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({
selector: 'ds-item-page-date-field',
- templateUrl: './../item-page-specific-field.component.html'
+ templateUrl: '../item-page-field.component.html'
})
-export class ItemPageDateFieldComponent extends ItemPageSpecificFieldComponent {
+/**
+ * This component is used for displaying the issue date (dc.date.issued) metadata of an item
+ */
+export class ItemPageDateFieldComponent extends ItemPageFieldComponent {
+ /**
+ * The item to display metadata for
+ */
@Input() item: Item;
+ /**
+ * Separator string between multiple values of the metadata fields defined
+ * @type {string}
+ */
separator = ', ';
+ /**
+ * Fields (schema.element.qualifier) used to render their values.
+ * In this component, we want to display values for metadata 'dc.date.issued'
+ */
fields: string[] = [
'dc.date.issued'
];
+ /**
+ * Label i18n key for the rendered metadata
+ */
label = 'item.page.date';
}
diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts
new file mode 100644
index 0000000000..d8abd39cf3
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts
@@ -0,0 +1,45 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
+import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
+import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
+import { GenericItemPageFieldComponent } from './generic-item-page-field.component';
+
+let comp: GenericItemPageFieldComponent;
+let fixture: ComponentFixture;
+
+const mockValue = 'test value';
+const mockField = 'dc.test';
+const mockLabel = 'test label';
+const mockFields = [mockField];
+
+describe('GenericItemPageFieldComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [GenericItemPageFieldComponent, MetadataValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(GenericItemPageFieldComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(GenericItemPageFieldComponent);
+ comp = fixture.componentInstance;
+ comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
+ comp.fields = mockFields;
+ comp.label = mockLabel;
+ fixture.detectChanges();
+ }));
+
+ it('should display display the correct metadata value', () => {
+ expect(fixture.nativeElement.innerHTML).toContain(mockValue);
+ });
+});
diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts
new file mode 100644
index 0000000000..ee7d27a11f
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts
@@ -0,0 +1,38 @@
+import { Component, Input } from '@angular/core';
+
+import { Item } from '../../../../../core/shared/item.model';
+import { ItemPageFieldComponent } from '../item-page-field.component';
+
+@Component({
+ selector: 'ds-generic-item-page-field',
+ templateUrl: '../item-page-field.component.html'
+})
+/**
+ * This component can be used to represent metadata on a simple item page.
+ * It is the most generic way of displaying metadata values
+ * It expects 4 parameters: The item, a seperator, the metadata keys and an i18n key
+ */
+export class GenericItemPageFieldComponent extends ItemPageFieldComponent {
+
+ /**
+ * The item to display metadata for
+ */
+ @Input() item: Item;
+
+ /**
+ * Separator string between multiple values of the metadata fields defined
+ * @type {string}
+ */
+ @Input() separator: string;
+
+ /**
+ * Fields (schema.element.qualifier) used to render their values.
+ */
+ @Input() fields: string[];
+
+ /**
+ * Label i18n key for the rendered metadata
+ */
+ @Input() label: string;
+
+}
diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-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 d6a569198c..fd3055d197 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 @@
-
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts
new file mode 100644
index 0000000000..ea6e722c66
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts
@@ -0,0 +1,63 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { Item } from '../../../../core/shared/item.model';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
+import { Observable } from 'rxjs';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { ItemPageFieldComponent } from './item-page-field.component';
+import { MetadataValuesComponent } from '../../../field-components/metadata-values/metadata-values.component';
+import { of as observableOf } from 'rxjs';
+import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models';
+
+let comp: ItemPageFieldComponent;
+let fixture: ComponentFixture
;
+
+const mockValue = 'test value';
+const mockField = 'dc.test';
+const mockLabel = 'test label';
+const mockFields = [mockField];
+
+describe('ItemPageFieldComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [ItemPageFieldComponent, MetadataValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemPageFieldComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemPageFieldComponent);
+ comp = fixture.componentInstance;
+ comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
+ comp.fields = mockFields;
+ comp.label = mockLabel;
+ fixture.detectChanges();
+ }));
+
+ it('should display display the correct metadata value', () => {
+ expect(fixture.nativeElement.innerHTML).toContain(mockValue);
+ });
+});
+
+export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item {
+ const item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: new MetadataMap()
+ });
+ item.metadata[field] = [{
+ language: 'en_US',
+ value: value
+ }] as MetadataValue[];
+ return item;
+}
diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts
similarity index 82%
rename from src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts
rename to src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts
index f69671a5b5..ce2b110efd 100644
--- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts
+++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts
@@ -9,10 +9,13 @@ import { Item } from '../../../../core/shared/item.model';
*/
@Component({
- templateUrl: './item-page-specific-field.component.html'
+ templateUrl: './item-page-field.component.html'
})
-export class ItemPageSpecificFieldComponent {
+export class ItemPageFieldComponent {
+ /**
+ * The item to display metadata for
+ */
@Input() item: Item;
/**
diff --git a/src/app/+item-page/simple/field-components/specific-field/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 aac85d335f..43bd20d0f6 100644
--- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html
+++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html
@@ -1,3 +1,6 @@
+
+ {{ type.toLowerCase() + '.page.titleprefix' | translate }}
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts
new file mode 100644
index 0000000000..cb1ba6a4bc
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts
@@ -0,0 +1,41 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
+import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
+import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
+import { ItemPageTitleFieldComponent } from './item-page-title-field.component';
+
+let comp: ItemPageTitleFieldComponent;
+let fixture: ComponentFixture;
+
+const mockField = 'dc.title';
+const mockValue = 'test value';
+
+describe('ItemPageTitleFieldComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [ItemPageTitleFieldComponent, MetadataValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemPageTitleFieldComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemPageTitleFieldComponent);
+ comp = fixture.componentInstance;
+ comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
+ fixture.detectChanges();
+ }));
+
+ it('should display display the correct metadata value', () => {
+ expect(fixture.nativeElement.innerHTML).toContain(mockValue);
+ });
+});
diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts
index be8102359a..c67d8bcf62 100644
--- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts
+++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts
@@ -1,18 +1,32 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model';
-import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component';
+import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({
selector: 'ds-item-page-title-field',
templateUrl: './item-page-title-field.component.html'
})
-export class ItemPageTitleFieldComponent extends ItemPageSpecificFieldComponent {
+/**
+ * This component is used for displaying the title (dc.title) of an item
+ */
+export class ItemPageTitleFieldComponent extends ItemPageFieldComponent {
+ /**
+ * The item to display metadata for
+ */
@Input() item: Item;
+ /**
+ * Separator string between multiple values of the metadata fields defined
+ * @type {string}
+ */
separator: string;
+ /**
+ * Fields (schema.element.qualifier) used to render their values.
+ * In this component, we want to display values for metadata 'dc.title'
+ */
fields: string[] = [
'dc.title'
];
diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html
index a5561b22e5..2b19754127 100644
--- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html
+++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html
@@ -1,3 +1,3 @@
-
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts
new file mode 100644
index 0000000000..4511f16aae
--- /dev/null
+++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts
@@ -0,0 +1,41 @@
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
+import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
+import { ItemPageUriFieldComponent } from './item-page-uri-field.component';
+import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component';
+
+let comp: ItemPageUriFieldComponent;
+let fixture: ComponentFixture
;
+
+const mockField = 'dc.identifier.uri';
+const mockValue = 'test value';
+
+describe('ItemPageUriFieldComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemPageUriFieldComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemPageUriFieldComponent);
+ comp = fixture.componentInstance;
+ comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
+ fixture.detectChanges();
+ }));
+
+ it('should display display the correct metadata value', () => {
+ expect(fixture.nativeElement.innerHTML).toContain(mockValue);
+ });
+});
diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts
index 4f06337032..c9cd5f1a00 100644
--- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts
+++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts
@@ -1,22 +1,39 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model';
-import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component';
+import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({
selector: 'ds-item-page-uri-field',
templateUrl: './item-page-uri-field.component.html'
})
-export class ItemPageUriFieldComponent extends ItemPageSpecificFieldComponent {
+/**
+ * This component is used for displaying the uri (dc.identifier.uri) metadata of an item
+ */
+export class ItemPageUriFieldComponent extends ItemPageFieldComponent {
+ /**
+ * The item to display metadata for
+ */
@Input() item: Item;
+ /**
+ * Separator string between multiple values of the metadata fields defined
+ * @type {string}
+ */
separator: string;
+ /**
+ * Fields (schema.element.qualifier) used to render their values.
+ * In this component, we want to display values for metadata 'dc.identifier.uri'
+ */
fields: string[] = [
'dc.identifier.uri'
];
+ /**
+ * Label i18n key for the rendered metadata
+ */
label = 'item.page.uri';
}
diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html
index 98b98a5e32..b6de496dc4 100644
--- a/src/app/+item-page/simple/item-page.component.html
+++ b/src/app/+item-page/simple/item-page.component.html
@@ -1,27 +1,7 @@
diff --git a/src/app/+item-page/simple/item-page.component.scss b/src/app/+item-page/simple/item-page.component.scss
index 50be6f5ad0..4c26cf08fb 100644
--- a/src/app/+item-page/simple/item-page.component.scss
+++ b/src/app/+item-page/simple/item-page.component.scss
@@ -1 +1,9 @@
@import '../../../styles/variables.scss';
+@import '../../../styles/mixins.scss';
+
+@include media-breakpoint-down(md) {
+ .container {
+ width: 100%;
+ max-width: none;
+ }
+}
diff --git a/src/app/+item-page/simple/item-page.component.spec.ts b/src/app/+item-page/simple/item-page.component.spec.ts
new file mode 100644
index 0000000000..e1202ab725
--- /dev/null
+++ b/src/app/+item-page/simple/item-page.component.spec.ts
@@ -0,0 +1,91 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
+import { ItemDataService } from '../../core/data/item-data.service';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { ItemPageComponent } from './item-page.component';
+import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
+import { MetadataService } from '../../core/metadata/metadata.service';
+import { VarDirective } from '../../shared/utils/var.directive';
+import { RemoteData } from '../../core/data/remote-data';
+import { Item } from '../../core/shared/item.model';
+import { PaginatedList } from '../../core/data/paginated-list';
+import { PageInfo } from '../../core/shared/page-info.model';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { By } from '@angular/platform-browser';
+import { createRelationshipsObservable } from './item-types/shared/item.component.spec';
+import { of as observableOf } from 'rxjs';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: [],
+ relationships: createRelationshipsObservable()
+});
+
+describe('ItemPageComponent', () => {
+ let comp: ItemPageComponent;
+ let fixture: ComponentFixture
;
+
+ const mockMetadataService = {
+ /* tslint:disable:no-empty */
+ processRemoteData: () => {}
+ /* tslint:enable:no-empty */
+ };
+ const mockRoute = Object.assign(new ActivatedRouteStub(), {
+ data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) })
+ });
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ }), BrowserAnimationsModule],
+ declarations: [ItemPageComponent, VarDirective],
+ providers: [
+ {provide: ActivatedRoute, useValue: mockRoute},
+ {provide: ItemDataService, useValue: {}},
+ {provide: MetadataService, useValue: mockMetadataService},
+ {provide: Router, useValue: {}}
+ ],
+
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemPageComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemPageComponent);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ describe('when the item is loading', () => {
+ beforeEach(() => {
+ comp.itemRD$ = observableOf(new RemoteData(true, true, true, null, undefined));
+ fixture.detectChanges();
+ });
+
+ it('should display a loading component', () => {
+ const loading = fixture.debugElement.query(By.css('ds-loading'));
+ expect(loading.nativeElement).toBeDefined();
+ });
+ });
+
+ describe('when the item failed loading', () => {
+ beforeEach(() => {
+ comp.itemRD$ = observableOf(new RemoteData(false, false, false, null, undefined));
+ fixture.detectChanges();
+ });
+
+ it('should display an error component', () => {
+ const error = fixture.debugElement.query(By.css('ds-error'));
+ expect(error.nativeElement).toBeDefined();
+ });
+ });
+
+});
diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts
index 35162b011f..89d5977583 100644
--- a/src/app/+item-page/simple/item-page.component.ts
+++ b/src/app/+item-page/simple/item-page.component.ts
@@ -1,7 +1,7 @@
-import {mergeMap, filter, map} from 'rxjs/operators';
+import { mergeMap, filter, map, take, tap } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../core/data/item-data.service';
@@ -14,6 +14,8 @@ import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade';
import { hasValue } from '../../shared/empty.util';
+import { redirectToPageNotFoundOn404 } from '../../core/shared/operators';
+import { ItemViewMode } from '../../shared/items/item-type-decorator';
/**
* This component renders a simple item page.
@@ -29,28 +31,33 @@ import { hasValue } from '../../shared/empty.util';
})
export class ItemPageComponent implements OnInit {
+ /**
+ * The item's id
+ */
id: number;
- private sub: any;
-
+ /**
+ * The item wrapped in a remote-data object
+ */
itemRD$: Observable>;
- thumbnail$: Observable;
+ /**
+ * The view-mode we're currently on
+ */
+ viewMode = ItemViewMode.Full;
constructor(
private route: ActivatedRoute,
+ private router: Router,
private items: ItemDataService,
- private metadataService: MetadataService
- ) {
-
- }
+ private metadataService: MetadataService,
+ ) { }
ngOnInit(): void {
- this.itemRD$ = this.route.data.pipe(map((data) => data.item));
+ this.itemRD$ = this.route.data.pipe(
+ map((data) => data.item as RemoteData- ),
+ redirectToPageNotFoundOn404(this.router)
+ );
this.metadataService.processRemoteData(this.itemRD$);
- this.thumbnail$ = this.itemRD$.pipe(
- map((rd: RemoteData
- ) => rd.payload),
- filter((item: Item) => hasValue(item)),
- mergeMap((item: Item) => item.getThumbnail()),);
}
}
diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html
new file mode 100644
index 0000000000..5d96abb82b
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html
@@ -0,0 +1,50 @@
+
+ {{'journalissue.page.titleprefix' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss
new file mode 100644
index 0000000000..3575cae797
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss
@@ -0,0 +1 @@
+@import '../../../../../styles/variables.scss';
diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts
new file mode 100644
index 0000000000..24b18af96e
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts
@@ -0,0 +1,40 @@
+import { Item } from '../../../../core/shared/item.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
+import { JournalIssueComponent } from './journal-issue.component';
+import { of as observableOf } from 'rxjs';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: {
+ 'journalissue.identifier.number': [
+ {
+ language: 'en_US',
+ value: '1234'
+ }
+ ],
+ 'journalissue.issuedate': [
+ {
+ language: 'en_US',
+ value: '2018'
+ }
+ ],
+ 'journalissue.identifier.description': [
+ {
+ language: 'en_US',
+ value: 'desc'
+ }
+ ],
+ 'journalissue.identifier.keyword': [
+ {
+ language: 'en_US',
+ value: 'keyword'
+ }
+ ]
+ },
+ relationships: createRelationshipsObservable()
+});
+
+describe('JournalIssueComponent', getItemPageFieldsTest(mockItem, JournalIssueComponent));
diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts
new file mode 100644
index 0000000000..77ed54d67f
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts
@@ -0,0 +1,51 @@
+import { Component, Inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { ItemComponent } from '../shared/item.component';
+import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils';
+
+@rendersItemType('JournalIssue', ItemViewMode.Full)
+@Component({
+ selector: 'ds-journal-issue',
+ styleUrls: ['./journal-issue.component.scss'],
+ templateUrl: './journal-issue.component.html'
+})
+/**
+ * The component for displaying metadata and relations of an item of the type Journal Issue
+ */
+export class JournalIssueComponent extends ItemComponent {
+ /**
+ * The volumes related to this journal issue
+ */
+ volumes$: Observable- ;
+
+ /**
+ * The publications related to this journal issue
+ */
+ publications$: Observable
- ;
+
+ constructor(
+ @Inject(ITEM) public item: Item,
+ private ids: ItemDataService
+ ) {
+ super(item);
+ }
+ ngOnInit(): void {
+ super.ngOnInit();
+
+ if (isNotEmpty(this.resolvedRelsAndTypes$)) {
+ this.volumes$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isJournalVolumeOfIssue'),
+ relationsToItems(this.item.id, this.ids)
+ );
+ this.publications$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isPublicationOfJournalIssue'),
+ relationsToItems(this.item.id, this.ids)
+ );
+ }
+ }
+}
diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html
new file mode 100644
index 0000000000..18bf1701fc
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html
@@ -0,0 +1,37 @@
+
+ {{'journalvolume.page.titleprefix' | translate}}
+
+
diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss
new file mode 100644
index 0000000000..3575cae797
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss
@@ -0,0 +1 @@
+@import '../../../../../styles/variables.scss';
diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts
new file mode 100644
index 0000000000..a6f32e9b5f
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts
@@ -0,0 +1,34 @@
+import { Item } from '../../../../core/shared/item.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
+import { JournalVolumeComponent } from './journal-volume.component';
+import { of as observableOf } from 'rxjs';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: {
+ 'journalvolume.identifier.volume': [
+ {
+ language: 'en_US',
+ value: '1234'
+ }
+ ],
+ 'journalvolume.issuedate': [
+ {
+ language: 'en_US',
+ value: '2018'
+ }
+ ],
+ 'journalvolume.identifier.description': [
+ {
+ language: 'en_US',
+ value: 'desc'
+ }
+ ]
+ },
+ relationships: createRelationshipsObservable()
+});
+
+describe('JournalVolumeComponent', getItemPageFieldsTest(mockItem, JournalVolumeComponent));
diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts
new file mode 100644
index 0000000000..616d96178a
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts
@@ -0,0 +1,51 @@
+import { Component, Inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { ItemComponent } from '../shared/item.component';
+import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils';
+
+@rendersItemType('JournalVolume', ItemViewMode.Full)
+@Component({
+ selector: 'ds-journal-volume',
+ styleUrls: ['./journal-volume.component.scss'],
+ templateUrl: './journal-volume.component.html'
+})
+/**
+ * The component for displaying metadata and relations of an item of the type Journal Volume
+ */
+export class JournalVolumeComponent extends ItemComponent {
+ /**
+ * The journals related to this journal volume
+ */
+ journals$: Observable- ;
+
+ /**
+ * The journal issues related to this journal volume
+ */
+ issues$: Observable
- ;
+
+ constructor(
+ @Inject(ITEM) public item: Item,
+ private ids: ItemDataService
+ ) {
+ super(item);
+ }
+ ngOnInit(): void {
+ super.ngOnInit();
+
+ if (isNotEmpty(this.resolvedRelsAndTypes$)) {
+ this.journals$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isJournalOfVolume'),
+ relationsToItems(this.item.id, this.ids)
+ );
+ this.issues$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isIssueOfJournalVolume'),
+ relationsToItems(this.item.id, this.ids)
+ );
+ }
+ }
+}
diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.html b/src/app/+item-page/simple/item-types/journal/journal.component.html
new file mode 100644
index 0000000000..2ab3430256
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal/journal.component.html
@@ -0,0 +1,42 @@
+
+ {{'journal.page.titleprefix' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.scss b/src/app/+item-page/simple/item-types/journal/journal.component.scss
new file mode 100644
index 0000000000..3575cae797
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal/journal.component.scss
@@ -0,0 +1 @@
+@import '../../../../../styles/variables.scss';
diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts
new file mode 100644
index 0000000000..08e8859b35
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts
@@ -0,0 +1,92 @@
+import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { By } from '@angular/platform-browser';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
+import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { JournalComponent } from './journal.component';
+import { of as observableOf } from 'rxjs';
+
+let comp: JournalComponent;
+let fixture: ComponentFixture;
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: {
+ 'journal.identifier.issn': [
+ {
+ language: 'en_US',
+ value: '1234'
+ }
+ ],
+ 'journal.publisher': [
+ {
+ language: 'en_US',
+ value: 'a publisher'
+ }
+ ],
+ 'journal.identifier.description': [
+ {
+ language: 'en_US',
+ value: 'desc'
+ }
+ ]
+ }
+});
+
+describe('JournalComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe],
+ providers: [
+ {provide: ITEM, useValue: mockItem},
+ {provide: ItemDataService, useValue: {}},
+ {provide: TruncatableService, useValue: {}}
+ ],
+
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(JournalComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(JournalComponent);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ for (const key of Object.keys(mockItem.metadata)) {
+ it(`should be calling a component with metadata field ${key}`, () => {
+ const fields = fixture.debugElement.queryAll(By.css('.item-page-fields'));
+ expect(containsFieldInput(fields, key)).toBeTruthy();
+ });
+ }
+});
+
+function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean {
+ for (const field of fields) {
+ const fieldComp = field.componentInstance;
+ if (isNotEmpty(fieldComp.fields)) {
+ if (fieldComp.fields.indexOf(metadataKey) > -1) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.ts b/src/app/+item-page/simple/item-types/journal/journal.component.ts
new file mode 100644
index 0000000000..0799f5c736
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/journal/journal.component.ts
@@ -0,0 +1,42 @@
+import { Component, Inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { ItemComponent } from '../shared/item.component';
+import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils';
+
+@rendersItemType('Journal', ItemViewMode.Full)
+@Component({
+ selector: 'ds-journal',
+ styleUrls: ['./journal.component.scss'],
+ templateUrl: './journal.component.html'
+})
+/**
+ * The component for displaying metadata and relations of an item of the type Journal
+ */
+export class JournalComponent extends ItemComponent {
+ /**
+ * The volumes related to this journal
+ */
+ volumes$: Observable- ;
+
+ constructor(
+ @Inject(ITEM) public item: Item,
+ private ids: ItemDataService
+ ) {
+ super(item);
+ }
+ ngOnInit(): void {
+ super.ngOnInit();
+
+ if (isNotEmpty(this.resolvedRelsAndTypes$)) {
+ this.volumes$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isVolumeOfJournal'),
+ relationsToItems(this.item.id, this.ids)
+ );
+ }
+ }
+}
diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html
new file mode 100644
index 0000000000..0446ac6861
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html
@@ -0,0 +1,49 @@
+
+ {{'orgunit.page.titleprefix' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss
new file mode 100644
index 0000000000..3575cae797
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss
@@ -0,0 +1 @@
+@import '../../../../../styles/variables.scss';
diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts
new file mode 100644
index 0000000000..fa5396fb3d
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts
@@ -0,0 +1,46 @@
+import { Item } from '../../../../core/shared/item.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
+import { OrgunitComponent } from './orgunit.component';
+import { of as observableOf } from 'rxjs';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: {
+ 'orgunit.identifier.dateestablished': [
+ {
+ language: 'en_US',
+ value: '2018'
+ }
+ ],
+ 'orgunit.identifier.city': [
+ {
+ language: 'en_US',
+ value: 'New York'
+ }
+ ],
+ 'orgunit.identifier.country': [
+ {
+ language: 'en_US',
+ value: 'USA'
+ }
+ ],
+ 'orgunit.identifier.id': [
+ {
+ language: 'en_US',
+ value: '1'
+ }
+ ],
+ 'orgunit.identifier.description': [
+ {
+ language: 'en_US',
+ value: 'desc'
+ }
+ ]
+ },
+ relationships: createRelationshipsObservable()
+});
+
+describe('OrgUnitComponent', getItemPageFieldsTest(mockItem, OrgunitComponent));
diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts
new file mode 100644
index 0000000000..96dc9a5960
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts
@@ -0,0 +1,62 @@
+import { Component, Inject, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { ItemComponent } from '../shared/item.component';
+import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils';
+
+@rendersItemType('OrgUnit', ItemViewMode.Full)
+@Component({
+ selector: 'ds-orgunit',
+ styleUrls: ['./orgunit.component.scss'],
+ templateUrl: './orgunit.component.html'
+})
+/**
+ * The component for displaying metadata and relations of an item of the type Organisation Unit
+ */
+export class OrgunitComponent extends ItemComponent implements OnInit {
+ /**
+ * The people related to this organisation unit
+ */
+ people$: Observable- ;
+
+ /**
+ * The projects related to this organisation unit
+ */
+ projects$: Observable
- ;
+
+ /**
+ * The publications related to this organisation unit
+ */
+ publications$: Observable
- ;
+
+ constructor(
+ @Inject(ITEM) public item: Item,
+ private ids: ItemDataService
+ ) {
+ super(item);
+ }
+
+ ngOnInit(): void {
+ super.ngOnInit();
+
+ if (isNotEmpty(this.resolvedRelsAndTypes$)) {
+ this.people$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isPersonOfOrgUnit'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.projects$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isProjectOfOrgUnit'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.publications$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isPublicationOfOrgUnit'),
+ relationsToItems(this.item.id, this.ids)
+ );
+ }
+ }}
diff --git a/src/app/+item-page/simple/item-types/person/person.component.html b/src/app/+item-page/simple/item-types/person/person.component.html
new file mode 100644
index 0000000000..88cd647645
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/person/person.component.html
@@ -0,0 +1,58 @@
+
+ {{'person.page.titleprefix' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/+item-page/simple/item-types/person/person.component.scss b/src/app/+item-page/simple/item-types/person/person.component.scss
new file mode 100644
index 0000000000..3575cae797
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/person/person.component.scss
@@ -0,0 +1 @@
+@import '../../../../../styles/variables.scss';
diff --git a/src/app/+item-page/simple/item-types/person/person.component.spec.ts b/src/app/+item-page/simple/item-types/person/person.component.spec.ts
new file mode 100644
index 0000000000..cf0d5c197d
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/person/person.component.spec.ts
@@ -0,0 +1,58 @@
+import { Item } from '../../../../core/shared/item.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
+import { PersonComponent } from './person.component';
+import { of as observableOf } from 'rxjs';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: {
+ 'person.identifier.email': [
+ {
+ language: 'en_US',
+ value: 'fake@email.com'
+ }
+ ],
+ 'person.identifier.orcid': [
+ {
+ language: 'en_US',
+ value: 'ORCID-1'
+ }
+ ],
+ 'person.identifier.birthdate': [
+ {
+ language: 'en_US',
+ value: '1993'
+ }
+ ],
+ 'person.identifier.staffid': [
+ {
+ language: 'en_US',
+ value: '1'
+ }
+ ],
+ 'person.identifier.jobtitle': [
+ {
+ language: 'en_US',
+ value: 'Developer'
+ }
+ ],
+ 'person.identifier.lastname': [
+ {
+ language: 'en_US',
+ value: 'Doe'
+ }
+ ],
+ 'person.identifier.firstname': [
+ {
+ language: 'en_US',
+ value: 'John'
+ }
+ ]
+ },
+ relationships: createRelationshipsObservable()
+});
+
+describe('PersonComponent', getItemPageFieldsTest(mockItem, PersonComponent));
diff --git a/src/app/+item-page/simple/item-types/person/person.component.ts b/src/app/+item-page/simple/item-types/person/person.component.ts
new file mode 100644
index 0000000000..67a2ae7a2e
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/person/person.component.ts
@@ -0,0 +1,77 @@
+import { Component, Inject } from '@angular/core';
+import { Observable , of as observableOf } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { ItemComponent } from '../shared/item.component';
+import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils';
+
+@rendersItemType('Person', ItemViewMode.Full)
+@Component({
+ selector: 'ds-person',
+ styleUrls: ['./person.component.scss'],
+ templateUrl: './person.component.html'
+})
+/**
+ * The component for displaying metadata and relations of an item of the type Person
+ */
+export class PersonComponent extends ItemComponent {
+ /**
+ * The publications related to this person
+ */
+ publications$: Observable- ;
+
+ /**
+ * The projects related to this person
+ */
+ projects$: Observable
- ;
+
+ /**
+ * The organisation units related to this person
+ */
+ orgUnits$: Observable
- ;
+
+ /**
+ * The applied fixed filter
+ */
+ fixedFilter$: Observable;
+
+ /**
+ * The query used for applying the fixed filter
+ */
+ fixedFilterQuery: string;
+
+ constructor(
+ @Inject(ITEM) public item: Item,
+ private ids: ItemDataService,
+ private fixedFilterService: SearchFixedFilterService
+ ) {
+ super(item);
+ }
+ ngOnInit(): void {
+ super.ngOnInit();
+
+ if (isNotEmpty(this.resolvedRelsAndTypes$)) {
+ this.publications$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isPublicationOfAuthor'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.projects$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isProjectOfPerson'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.orgUnits$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isOrgUnitOfPerson'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.fixedFilterQuery = this.fixedFilterService.getQueryByRelations('isAuthorOfPublication', this.item.id);
+ this.fixedFilter$ = observableOf('publication');
+ }
+ }
+}
diff --git a/src/app/+item-page/simple/item-types/project/project.component.html b/src/app/+item-page/simple/item-types/project/project.component.html
new file mode 100644
index 0000000000..08e386182b
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/project/project.component.html
@@ -0,0 +1,57 @@
+
+ {{'project.page.titleprefix' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/+item-page/simple/item-types/project/project.component.scss b/src/app/+item-page/simple/item-types/project/project.component.scss
new file mode 100644
index 0000000000..3575cae797
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/project/project.component.scss
@@ -0,0 +1 @@
+@import '../../../../../styles/variables.scss';
diff --git a/src/app/+item-page/simple/item-types/project/project.component.spec.ts b/src/app/+item-page/simple/item-types/project/project.component.spec.ts
new file mode 100644
index 0000000000..9b54ff9a41
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/project/project.component.spec.ts
@@ -0,0 +1,46 @@
+import { Item } from '../../../../core/shared/item.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
+import { ProjectComponent } from './project.component';
+import { of as observableOf } from 'rxjs';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: {
+ 'project.identifier.status': [
+ {
+ language: 'en_US',
+ value: 'published'
+ }
+ ],
+ 'project.identifier.id': [
+ {
+ language: 'en_US',
+ value: '1'
+ }
+ ],
+ 'project.identifier.expectedcompletion': [
+ {
+ language: 'en_US',
+ value: 'exp comp'
+ }
+ ],
+ 'project.identifier.description': [
+ {
+ language: 'en_US',
+ value: 'keyword'
+ }
+ ],
+ 'project.identifier.keyword': [
+ {
+ language: 'en_US',
+ value: 'keyword'
+ }
+ ]
+ },
+ relationships: createRelationshipsObservable()
+});
+
+describe('ProjectComponent', getItemPageFieldsTest(mockItem, ProjectComponent));
diff --git a/src/app/+item-page/simple/item-types/project/project.component.ts b/src/app/+item-page/simple/item-types/project/project.component.ts
new file mode 100644
index 0000000000..eafef36307
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/project/project.component.ts
@@ -0,0 +1,71 @@
+import { Component, Inject, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
+import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { ItemComponent } from '../shared/item.component';
+import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils';
+
+@rendersItemType('Project', ItemViewMode.Full)
+@Component({
+ selector: 'ds-project',
+ styleUrls: ['./project.component.scss'],
+ templateUrl: './project.component.html'
+})
+/**
+ * The component for displaying metadata and relations of an item of the type Project
+ */
+export class ProjectComponent extends ItemComponent implements OnInit {
+ /**
+ * The contributors related to this project
+ */
+ contributors$: Observable;
+
+ /**
+ * The people related to this project
+ */
+ people$: Observable- ;
+
+ /**
+ * The publications related to this project
+ */
+ publications$: Observable
- ;
+
+ /**
+ * The organisation units related to this project
+ */
+ orgUnits$: Observable
- ;
+
+ constructor(
+ @Inject(ITEM) public item: Item,
+ private ids: ItemDataService
+ ) {
+ super(item);
+ }
+
+ ngOnInit(): void {
+ super.ngOnInit();
+
+ if (isNotEmpty(this.resolvedRelsAndTypes$)) {
+ this.contributors$ = this.buildRepresentations('OrgUnit', 'project.contributor.other', this.ids);
+
+ this.people$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isPersonOfProject'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.publications$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isPublicationOfProject'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.orgUnits$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isOrgUnitOfProject'),
+ relationsToItems(this.item.id, this.ids)
+ );
+ }
+ }
+}
diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.html b/src/app/+item-page/simple/item-types/publication/publication.component.html
new file mode 100644
index 0000000000..37135c6036
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/publication/publication.component.html
@@ -0,0 +1,60 @@
+
+ {{'publication.page.titleprefix' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.scss b/src/app/+item-page/simple/item-types/publication/publication.component.scss
new file mode 100644
index 0000000000..3575cae797
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/publication/publication.component.scss
@@ -0,0 +1 @@
+@import '../../../../../styles/variables.scss';
diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts
new file mode 100644
index 0000000000..48a7a05f45
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts
@@ -0,0 +1,90 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
+import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
+import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
+import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { Item } from '../../../../core/shared/item.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { By } from '@angular/platform-browser';
+import { createRelationshipsObservable } from '../shared/item.component.spec';
+import { PublicationComponent } from './publication.component';
+import { of as observableOf } from 'rxjs';
+import { MetadataMap } from '../../../../core/shared/metadata.models';
+
+const mockItem: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: new MetadataMap(),
+ relationships: createRelationshipsObservable()
+});
+
+describe('PublicationComponent', () => {
+ let comp: PublicationComponent;
+ let fixture: ComponentFixture;
+
+ const searchFixedFilterServiceStub = {
+ /* tslint:disable:no-empty */
+ getQueryByRelations: () => {}
+ /* tslint:enable:no-empty */
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [PublicationComponent, GenericItemPageFieldComponent, TruncatePipe],
+ providers: [
+ {provide: ITEM, useValue: mockItem},
+ {provide: ItemDataService, useValue: {}},
+ {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub},
+ {provide: TruncatableService, useValue: {}}
+ ],
+
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(PublicationComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(PublicationComponent);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ it('should contain a component to display the date', () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field'));
+ expect(fields.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should contain a component to display the author', () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field'));
+ expect(fields.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should contain a component to display the abstract', () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field'));
+ expect(fields.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should contain a component to display the uri', () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field'));
+ expect(fields.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should contain a component to display the collections', () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections'));
+ expect(fields.length).toBeGreaterThanOrEqual(1);
+ });
+
+});
diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.ts b/src/app/+item-page/simple/item-types/publication/publication.component.ts
new file mode 100644
index 0000000000..8798b3c1cf
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/publication/publication.component.ts
@@ -0,0 +1,74 @@
+import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import {
+ DEFAULT_ITEM_TYPE, ItemViewMode,
+ rendersItemType
+} from '../../../../shared/items/item-type-decorator';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { ItemComponent } from '../shared/item.component';
+import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
+import { filterRelationsByTypeLabel, relationsToItems } from '../shared/item-relationships-utils';
+
+@rendersItemType('Publication', ItemViewMode.Full)
+@rendersItemType(DEFAULT_ITEM_TYPE, ItemViewMode.Full)
+@Component({
+ selector: 'ds-publication',
+ styleUrls: ['./publication.component.scss'],
+ templateUrl: './publication.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PublicationComponent extends ItemComponent implements OnInit {
+ /**
+ * The authors related to this publication
+ */
+ authors$: Observable;
+
+ /**
+ * The projects related to this publication
+ */
+ projects$: Observable- ;
+
+ /**
+ * The organisation units related to this publication
+ */
+ orgUnits$: Observable
- ;
+
+ /**
+ * The journal issues related to this publication
+ */
+ journalIssues$: Observable
- ;
+
+ constructor(
+ @Inject(ITEM) public item: Item,
+ private ids: ItemDataService
+ ) {
+ super(item);
+ }
+
+ ngOnInit(): void {
+ super.ngOnInit();
+
+ if (this.resolvedRelsAndTypes$) {
+
+ this.authors$ = this.buildRepresentations('Person', 'dc.contributor.author', this.ids);
+
+ this.projects$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isProjectOfPublication'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.orgUnits$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isOrgUnitOfPublication'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ this.journalIssues$ = this.resolvedRelsAndTypes$.pipe(
+ filterRelationsByTypeLabel('isJournalIssueOfPublication'),
+ relationsToItems(this.item.id, this.ids)
+ );
+
+ }
+ }
+}
diff --git a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts
new file mode 100644
index 0000000000..7c632a9365
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts
@@ -0,0 +1,121 @@
+import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model';
+import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
+import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
+import { MetadataValue } from '../../../../core/shared/metadata.models';
+import { getSucceededRemoteData } from '../../../../core/shared/operators';
+import { hasValue } from '../../../../shared/empty.util';
+import { Observable } from 'rxjs/internal/Observable';
+import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
+import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
+import { distinctUntilChanged, flatMap, map } from 'rxjs/operators';
+import { of as observableOf, zip as observableZip } from 'rxjs';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { Item } from '../../../../core/shared/item.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+
+/**
+ * Operator for comparing arrays using a mapping function
+ * The mapping function should turn the source array into an array of basic types, so that the array can
+ * be compared using these basic types.
+ * For example: "(o) => o.id" will compare the two arrays by comparing their content by id.
+ * @param mapFn Function for mapping the arrays
+ */
+export const compareArraysUsing = (mapFn: (t: T) => any) =>
+ (a: T[], b: T[]): boolean => {
+ if (!Array.isArray(a) || ! Array.isArray(b)) {
+ return false
+ }
+
+ const aIds = a.map(mapFn);
+ const bIds = b.map(mapFn);
+
+ return aIds.length === bIds.length &&
+ aIds.every((e) => bIds.includes(e)) &&
+ bIds.every((e) => aIds.includes(e));
+ };
+
+/**
+ * Operator for comparing arrays using the object's ids
+ */
+export const compareArraysUsingIds = () =>
+ compareArraysUsing((t: T) => hasValue(t) ? t.id : undefined);
+
+/**
+ * Fetch the relationships which match the type label given
+ * @param {string} label Type label
+ * @returns {(source: Observable<[Relationship[] , RelationshipType[]]>) => Observable}
+ */
+export const filterRelationsByTypeLabel = (label: string) =>
+ (source: Observable<[Relationship[], RelationshipType[]]>): Observable =>
+ source.pipe(
+ map(([relsCurrentPage, relTypesCurrentPage]) =>
+ relsCurrentPage.filter((rel: Relationship, idx: number) =>
+ hasValue(relTypesCurrentPage[idx]) && (relTypesCurrentPage[idx].leftLabel === label ||
+ relTypesCurrentPage[idx].rightLabel === label)
+ )
+ ),
+ distinctUntilChanged(compareArraysUsingIds())
+ );
+
+/**
+ * Operator for turning a list of relationships into a list of the relevant items
+ * @param {string} thisId The item's id of which the relations belong to
+ * @param {ItemDataService} ids The ItemDataService to fetch items from the REST API
+ * @returns {(source: Observable) => Observable
- }
+ */
+export const relationsToItems = (thisId: string, ids: ItemDataService) =>
+ (source: Observable): Observable
- =>
+ source.pipe(
+ flatMap((rels: Relationship[]) =>
+ observableZip(
+ ...rels.map((rel: Relationship) => {
+ let queryId = rel.leftId;
+ if (rel.leftId === thisId) {
+ queryId = rel.rightId;
+ }
+ return ids.findById(queryId);
+ })
+ )
+ ),
+ map((arr: Array>) =>
+ arr
+ .filter((d: RemoteData
- ) => d.hasSucceeded)
+ .map((d: RemoteData
- ) => d.payload)),
+ distinctUntilChanged(compareArraysUsingIds()),
+ );
+
+/**
+ * Operator for turning a list of relationships into a list of metadatarepresentations given the original metadata
+ * @param parentId The id of the parent item
+ * @param itemType The type of relation this list resembles (for creating representations)
+ * @param metadata The list of original Metadatum objects
+ * @param ids The ItemDataService to use for fetching Items from the Rest API
+ */
+export const relationsToRepresentations = (parentId: string, itemType: string, metadata: MetadataValue[], ids: ItemDataService) =>
+ (source: Observable): Observable =>
+ source.pipe(
+ flatMap((rels: Relationship[]) =>
+ observableZip(
+ ...metadata
+ .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum))
+ .map((metadatum: MetadataValue) => {
+ if (metadatum.isVirtual) {
+ const matchingRels = rels.filter((rel: Relationship) => ('' + rel.id) === metadatum.virtualValue);
+ if (matchingRels.length > 0) {
+ const matchingRel = matchingRels[0];
+ let queryId = matchingRel.leftId;
+ if (matchingRel.leftId === parentId) {
+ queryId = matchingRel.rightId;
+ }
+ return ids.findById(queryId).pipe(
+ getSucceededRemoteData(),
+ map((d: RemoteData
- ) => Object.assign(new ItemMetadataRepresentation(), d.payload))
+ );
+ }
+ } else {
+ return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum));
+ }
+ })
+ )
+ )
+ );
diff --git a/src/app/+item-page/simple/item-types/shared/item.component.spec.ts b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts
new file mode 100644
index 0000000000..a6b4dd801d
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts
@@ -0,0 +1,428 @@
+import { Item } from '../../../../core/shared/item.model';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
+import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
+import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
+import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { ItemComponent } from './item.component';
+import { of as observableOf } from 'rxjs';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { VarDirective } from '../../../../shared/utils/var.directive';
+import { Observable } from 'rxjs/internal/Observable';
+import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
+import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
+import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model';
+import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models';
+import { compareArraysUsing, compareArraysUsingIds } from './item-relationships-utils';
+
+/**
+ * Create a generic test for an item-page-fields component using a mockItem and the type of component
+ * @param {Item} mockItem The item to use for testing. The item needs to contain just the metadata necessary to
+ * execute the tests for it's component.
+ * @param component The type of component to create test cases for.
+ * @returns {() => void} Returns a specDefinition for the test.
+ */
+export function getItemPageFieldsTest(mockItem: Item, component) {
+ return () => {
+ let comp: any;
+ let fixture: ComponentFixture;
+
+ const searchFixedFilterServiceStub = {
+ /* tslint:disable:no-empty */
+ getQueryByRelations: () => {}
+ /* tslint:enable:no-empty */
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })],
+ declarations: [component, GenericItemPageFieldComponent, TruncatePipe],
+ providers: [
+ {provide: ITEM, useValue: mockItem},
+ {provide: ItemDataService, useValue: {}},
+ {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub},
+ {provide: TruncatableService, useValue: {}}
+ ],
+
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(component, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(component);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ for (const key of Object.keys(mockItem.metadata)) {
+ it(`should be calling a component with metadata field ${key}`, () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-generic-item-page-field'));
+ expect(containsFieldInput(fields, key)).toBeTruthy();
+ });
+ }
+ }
+}
+
+/**
+ * Checks whether in a list of debug elements, at least one of them contains a specific metadata key in their
+ * fields property.
+ * @param {DebugElement[]} fields List of debug elements to check
+ * @param {string} metadataKey A metadata key to look for
+ * @returns {boolean}
+ */
+export function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean {
+ for (const field of fields) {
+ const fieldComp = field.componentInstance;
+ if (isNotEmpty(fieldComp.fields)) {
+ if (fieldComp.fields.indexOf(metadataKey) > -1) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+export function createRelationshipsObservable() {
+ return observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [
+ Object.assign(new Relationship(), {
+ relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType()))
+ })
+ ])));
+}
+describe('ItemComponent', () => {
+ const arr1 = [
+ {
+ id: 1,
+ name: 'test'
+ },
+ {
+ id: 2,
+ name: 'another test'
+ },
+ {
+ id: 3,
+ name: 'one last test'
+ }
+ ];
+ const arrWithWrongId = [
+ {
+ id: 1,
+ name: 'test'
+ },
+ {
+ id: 5, // Wrong id on purpose
+ name: 'another test'
+ },
+ {
+ id: 3,
+ name: 'one last test'
+ }
+ ];
+ const arrWithWrongName = [
+ {
+ id: 1,
+ name: 'test'
+ },
+ {
+ id: 2,
+ name: 'wrong test' // Wrong name on purpose
+ },
+ {
+ id: 3,
+ name: 'one last test'
+ }
+ ];
+ const arrWithDifferentOrder = [arr1[0], arr1[2], arr1[1]];
+ const arrWithOneMore = [...arr1, {
+ id: 4,
+ name: 'fourth test'
+ }];
+ const arrWithAddedProperties = [
+ {
+ id: 1,
+ name: 'test',
+ extra: 'extra property'
+ },
+ {
+ id: 2,
+ name: 'another test',
+ extra: 'extra property'
+ },
+ {
+ id: 3,
+ name: 'one last test',
+ extra: 'extra property'
+ }
+ ];
+ const arrOfPrimitiveTypes = [1, 2, 3, 4];
+ const arrOfPrimitiveTypesWithOneWrong = [1, 5, 3, 4];
+ const arrOfPrimitiveTypesWithDifferentOrder = [1, 3, 2, 4];
+ const arrOfPrimitiveTypesWithOneMore = [1, 2, 3, 4, 5];
+
+ describe('when calling compareArraysUsing', () => {
+
+ describe('and comparing by id', () => {
+ const compare = compareArraysUsing((o) => o.id);
+
+ it('should return true when comparing the same array', () => {
+ expect(compare(arr1, arr1)).toBeTruthy();
+ });
+
+ it('should return true regardless of the order', () => {
+ expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy();
+ });
+
+ it('should return true regardless of other properties being different', () => {
+ expect(compare(arr1, arrWithWrongName)).toBeTruthy();
+ });
+
+ it('should return true regardless of extra properties', () => {
+ expect(compare(arr1, arrWithAddedProperties)).toBeTruthy();
+ });
+
+ it('should return false when the ids don\'t match', () => {
+ expect(compare(arr1, arrWithWrongId)).toBeFalsy();
+ });
+
+ it('should return false when the sizes don\'t match', () => {
+ expect(compare(arr1, arrWithOneMore)).toBeFalsy();
+ });
+ });
+
+ describe('and comparing by name', () => {
+ const compare = compareArraysUsing((o) => o.name);
+
+ it('should return true when comparing the same array', () => {
+ expect(compare(arr1, arr1)).toBeTruthy();
+ });
+
+ it('should return true regardless of the order', () => {
+ expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy();
+ });
+
+ it('should return true regardless of other properties being different', () => {
+ expect(compare(arr1, arrWithWrongId)).toBeTruthy();
+ });
+
+ it('should return true regardless of extra properties', () => {
+ expect(compare(arr1, arrWithAddedProperties)).toBeTruthy();
+ });
+
+ it('should return false when the names don\'t match', () => {
+ expect(compare(arr1, arrWithWrongName)).toBeFalsy();
+ });
+
+ it('should return false when the sizes don\'t match', () => {
+ expect(compare(arr1, arrWithOneMore)).toBeFalsy();
+ });
+ });
+
+ describe('and comparing by full objects', () => {
+ const compare = compareArraysUsing((o) => o);
+
+ it('should return true when comparing the same array', () => {
+ expect(compare(arr1, arr1)).toBeTruthy();
+ });
+
+ it('should return true regardless of the order', () => {
+ expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy();
+ });
+
+ it('should return false when extra properties are added', () => {
+ expect(compare(arr1, arrWithAddedProperties)).toBeFalsy();
+ });
+
+ it('should return false when the ids don\'t match', () => {
+ expect(compare(arr1, arrWithWrongId)).toBeFalsy();
+ });
+
+ it('should return false when the names don\'t match', () => {
+ expect(compare(arr1, arrWithWrongName)).toBeFalsy();
+ });
+
+ it('should return false when the sizes don\'t match', () => {
+ expect(compare(arr1, arrWithOneMore)).toBeFalsy();
+ });
+ });
+
+ describe('and comparing with primitive objects as source', () => {
+ const compare = compareArraysUsing((o) => o);
+
+ it('should return true when comparing the same array', () => {
+ expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypes)).toBeTruthy();
+ });
+
+ it('should return true regardless of the order', () => {
+ expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithDifferentOrder)).toBeTruthy();
+ });
+
+ it('should return false when at least one is wrong', () => {
+ expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneWrong)).toBeFalsy();
+ });
+
+ it('should return false when the sizes don\'t match', () => {
+ expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneMore)).toBeFalsy();
+ });
+ });
+
+ });
+
+ describe('when calling compareArraysUsingIds', () => {
+ const compare = compareArraysUsingIds();
+
+ it('should return true when comparing the same array', () => {
+ expect(compare(arr1 as any, arr1 as any)).toBeTruthy();
+ });
+
+ it('should return true regardless of the order', () => {
+ expect(compare(arr1 as any, arrWithDifferentOrder as any)).toBeTruthy();
+ });
+
+ it('should return true regardless of other properties being different', () => {
+ expect(compare(arr1 as any, arrWithWrongName as any)).toBeTruthy();
+ });
+
+ it('should return true regardless of extra properties', () => {
+ expect(compare(arr1 as any, arrWithAddedProperties as any)).toBeTruthy();
+ });
+
+ it('should return false when the ids don\'t match', () => {
+ expect(compare(arr1 as any, arrWithWrongId as any)).toBeFalsy();
+ });
+
+ it('should return false when the sizes don\'t match', () => {
+ expect(compare(arr1 as any, arrWithOneMore as any)).toBeFalsy();
+ });
+ });
+
+ describe('when calling buildRepresentations', () => {
+ let comp: ItemComponent;
+ let fixture: ComponentFixture;
+
+ const metadataField = 'dc.contributor.author';
+ const mockItem = Object.assign(new Item(), {
+ id: '1',
+ uuid: '1',
+ metadata: new MetadataMap(),
+ relationships: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [
+ Object.assign(new Relationship(), {
+ uuid: '123',
+ id: '123',
+ leftId: '1',
+ rightId: '2',
+ relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType()))
+ })
+ ])))
+ });
+ mockItem.metadata[metadataField] = [
+ {
+ value: 'Second value',
+ place: 1
+ },
+ {
+ value: 'Third value',
+ place: 2,
+ authority: 'virtual::123'
+ },
+ {
+ value: 'First value',
+ place: 0
+ },
+ {
+ value: 'Fourth value',
+ place: 3,
+ authority: '123'
+ }
+ ] as MetadataValue[];
+ const relatedItem = Object.assign(new Item(), {
+ id: '2',
+ metadata: Object.assign(new MetadataMap(), {
+ 'dc.title': [
+ {
+ language: 'en_US',
+ value: 'related item'
+ }
+ ]
+ })
+ });
+ const mockItemDataService = Object.assign({
+ findById: (id) => {
+ if (id === relatedItem.id) {
+ return observableOf(new RemoteData(false, false, true, null, relatedItem))
+ }
+ }
+ }) as ItemDataService;
+
+ let representations: Observable;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ }), BrowserAnimationsModule],
+ declarations: [ItemComponent, VarDirective],
+ providers: [
+ {provide: ITEM, useValue: mockItem}
+ ],
+
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(ItemComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(ItemComponent);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ representations = comp.buildRepresentations('bogus', metadataField, mockItemDataService);
+ }));
+
+ it('should contain exactly 4 metadata-representations', () => {
+ representations.subscribe((reps: MetadataRepresentation[]) => {
+ expect(reps.length).toEqual(4);
+ });
+ });
+
+ it('should have all the representations in the correct order', () => {
+ representations.subscribe((reps: MetadataRepresentation[]) => {
+ expect(reps[0].getValue()).toEqual('First value');
+ expect(reps[1].getValue()).toEqual('Second value');
+ expect(reps[2].getValue()).toEqual('related item');
+ expect(reps[3].getValue()).toEqual('Fourth value');
+ });
+ });
+
+ it('should have created the correct MetadatumRepresentation and ItemMetadataRepresentation objects for the correct Metadata', () => {
+ representations.subscribe((reps: MetadataRepresentation[]) => {
+ expect(reps[0] instanceof MetadatumRepresentation).toEqual(true);
+ expect(reps[1] instanceof MetadatumRepresentation).toEqual(true);
+ expect(reps[2] instanceof ItemMetadataRepresentation).toEqual(true);
+ expect(reps[3] instanceof MetadatumRepresentation).toEqual(true);
+ });
+ });
+ })
+
+});
diff --git a/src/app/+item-page/simple/item-types/shared/item.component.ts b/src/app/+item-page/simple/item-types/shared/item.component.ts
new file mode 100644
index 0000000000..c6d43aa6b3
--- /dev/null
+++ b/src/app/+item-page/simple/item-types/shared/item.component.ts
@@ -0,0 +1,79 @@
+import { Component, Inject, OnInit } from '@angular/core';
+import { combineLatest as observableCombineLatest, Observable, zip as observableZip } from 'rxjs';
+import { distinctUntilChanged, filter, flatMap, map } from 'rxjs/operators';
+import { ItemDataService } from '../../../../core/data/item-data.service';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
+import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
+import { Item } from '../../../../core/shared/item.model';
+import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
+import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
+import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
+import { compareArraysUsingIds, relationsToRepresentations } from './item-relationships-utils';
+
+@Component({
+ selector: 'ds-item',
+ template: ''
+})
+/**
+ * A generic component for displaying metadata and relations of an item
+ */
+export class ItemComponent implements OnInit {
+ /**
+ * Resolved relationships and types together in one observable
+ */
+ resolvedRelsAndTypes$: Observable<[Relationship[], RelationshipType[]]>;
+
+ constructor(
+ @Inject(ITEM) public item: Item
+ ) {}
+
+ ngOnInit(): void {
+ const relationships$ = this.item.relationships;
+ if (relationships$) {
+ const relsCurrentPage$ = relationships$.pipe(
+ filter((rd: RemoteData>) => rd.hasSucceeded),
+ getRemoteDataPayload(),
+ map((pl: PaginatedList) => pl.page),
+ distinctUntilChanged(compareArraysUsingIds())
+ );
+
+ const relTypesCurrentPage$ = relsCurrentPage$.pipe(
+ flatMap((rels: Relationship[]) =>
+ observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe(
+ map(([...arr]: Array>) => arr.map((d: RemoteData) => d.payload))
+ )
+ ),
+ distinctUntilChanged(compareArraysUsingIds())
+ );
+
+ this.resolvedRelsAndTypes$ = observableCombineLatest(
+ relsCurrentPage$,
+ relTypesCurrentPage$
+ );
+ }
+ }
+
+ /**
+ * Build a list of MetadataRepresentations for the current item. This combines all metadata and relationships of a
+ * certain type.
+ * @param itemType The type of item we're building representations of. Used for matching templates.
+ * @param metadataField The metadata field that resembles the item type.
+ * @param itemDataService ItemDataService to turn relations into items.
+ */
+ buildRepresentations(itemType: string, metadataField: string, itemDataService: ItemDataService): Observable {
+ const metadata = this.item.findMetadataSortedByPlace(metadataField);
+ const relsCurrentPage$ = this.item.relationships.pipe(
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ map((pl: PaginatedList) => pl.page),
+ distinctUntilChanged(compareArraysUsingIds())
+ );
+
+ return relsCurrentPage$.pipe(
+ relationsToRepresentations(this.item.id, itemType, metadata, itemDataService)
+ );
+ }
+
+}
diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html
new file mode 100644
index 0000000000..48eabf8451
--- /dev/null
+++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html
@@ -0,0 +1,5 @@
+ 0" [label]="label">
+
+
+
diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts
new file mode 100644
index 0000000000..f02625e8c7
--- /dev/null
+++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts
@@ -0,0 +1,40 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { By } from '@angular/platform-browser';
+import { MetadataRepresentationListComponent } from './metadata-representation-list.component';
+import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
+import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
+
+const itemType = 'type';
+const metadataRepresentation1 = new MetadatumRepresentation(itemType);
+const metadataRepresentation2 = new ItemMetadataRepresentation();
+const representations = [metadataRepresentation1, metadataRepresentation2];
+
+describe('MetadataRepresentationListComponent', () => {
+ let comp: MetadataRepresentationListComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [],
+ declarations: [MetadataRepresentationListComponent],
+ providers: [],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(MetadataRepresentationListComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(MetadataRepresentationListComponent);
+ comp = fixture.componentInstance;
+ comp.representations = representations;
+ fixture.detectChanges();
+ }));
+
+ it(`should load ${representations.length} item-type-switcher components`, () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher'));
+ expect(fields.length).toBe(representations.length);
+ });
+
+});
diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts
new file mode 100644
index 0000000000..f0dc222bf1
--- /dev/null
+++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts
@@ -0,0 +1,29 @@
+import { Component, Input } from '@angular/core';
+import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model';
+import { ItemViewMode } from '../../../shared/items/item-type-decorator';
+
+@Component({
+ selector: 'ds-metadata-representation-list',
+ templateUrl: './metadata-representation-list.component.html'
+})
+/**
+ * This component is used for displaying metadata
+ * It expects a list of MetadataRepresentation objects and a label to put on top of the list
+ */
+export class MetadataRepresentationListComponent {
+ /**
+ * A list of metadata-representations to display
+ */
+ @Input() representations: MetadataRepresentation[];
+
+ /**
+ * An i18n label to use as a title for the list
+ */
+ @Input() label: string;
+
+ /**
+ * The view-mode we're currently on
+ * @type {ElementViewMode}
+ */
+ viewMode = ItemViewMode.Metadata;
+}
diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html
new file mode 100644
index 0000000000..9ec082db73
--- /dev/null
+++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html
@@ -0,0 +1,6 @@
+
+
diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts
new file mode 100644
index 0000000000..e76a9cf3d0
--- /dev/null
+++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts
@@ -0,0 +1,56 @@
+import { RelatedEntitiesSearchComponent } from './related-entities-search.component';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { TranslateModule } from '@ngx-translate/core';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
+import { Item } from '../../../../core/shared/item.model';
+
+describe('RelatedEntitiesSearchComponent', () => {
+ let comp: RelatedEntitiesSearchComponent;
+ let fixture: ComponentFixture;
+ let fixedFilterService: SearchFixedFilterService;
+
+ const mockItem = Object.assign(new Item(), {
+ id: 'id1'
+ });
+ const mockRelationType = 'publicationsOfAuthor';
+ const mockRelationEntityType = 'publication';
+ const mockFilter= `f.${mockRelationType}=${mockItem.id}`;
+ const fixedFilterServiceStub = {
+ getFilterByRelation: () => mockFilter
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
+ declarations: [RelatedEntitiesSearchComponent],
+ providers: [
+ { provide: SearchFixedFilterService, useValue: fixedFilterServiceStub }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RelatedEntitiesSearchComponent);
+ comp = fixture.componentInstance;
+ fixedFilterService = (comp as any).fixedFilterService;
+ comp.relationType = mockRelationType;
+ comp.item = mockItem;
+ comp.relationEntityType = mockRelationEntityType;
+ fixture.detectChanges();
+ });
+
+ it('should create a fixedFilter', () => {
+ expect(comp.fixedFilter).toEqual(mockFilter);
+ });
+
+ it('should create a fixedFilter$', () => {
+ comp.fixedFilter$.subscribe((fixedFilter) => {
+ expect(fixedFilter).toEqual(mockRelationEntityType);
+ })
+ });
+
+});
diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts
new file mode 100644
index 0000000000..672655a8b8
--- /dev/null
+++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts
@@ -0,0 +1,64 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { Item } from '../../../../core/shared/item.model';
+import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { of } from 'rxjs/internal/observable/of';
+
+@Component({
+ selector: 'ds-related-entities-search',
+ templateUrl: './related-entities-search.component.html'
+})
+/**
+ * A component to show related items as search results.
+ * Related items can be facetted, or queried using an
+ * optional search box.
+ */
+export class RelatedEntitiesSearchComponent implements OnInit {
+
+ /**
+ * The type of relationship to fetch items for
+ * e.g. 'isAuthorOfPublication'
+ */
+ @Input() relationType: string;
+
+ /**
+ * The item to render relationships for
+ */
+ @Input() item: Item;
+
+ /**
+ * The entity type of the relationship items to be displayed
+ * e.g. 'publication'
+ * This determines the title of the search results (if search is enabled)
+ */
+ @Input() relationEntityType: string;
+
+ /**
+ * Whether or not the search bar and title should be displayed (defaults to true)
+ * @type {boolean}
+ */
+ @Input() searchEnabled = true;
+
+ /**
+ * The ratio of the sidebar's width compared to the search results (1-12) (defaults to 4)
+ * @type {number}
+ */
+ @Input() sideBarWidth = 4;
+
+ fixedFilter: string;
+ fixedFilter$: Observable;
+
+ constructor(private fixedFilterService: SearchFixedFilterService) {
+ }
+
+ ngOnInit(): void {
+ if (isNotEmpty(this.relationType) && isNotEmpty(this.item)) {
+ this.fixedFilter = this.fixedFilterService.getFilterByRelation(this.relationType, this.item.id);
+ }
+ if (isNotEmpty(this.relationEntityType)) {
+ this.fixedFilter$ = of(this.relationEntityType);
+ }
+ }
+
+}
diff --git a/src/app/+item-page/simple/related-items/related-items-component.ts b/src/app/+item-page/simple/related-items/related-items-component.ts
new file mode 100644
index 0000000000..7b54d7316a
--- /dev/null
+++ b/src/app/+item-page/simple/related-items/related-items-component.ts
@@ -0,0 +1,30 @@
+import { Component, Input } from '@angular/core';
+import { Item } from '../../../core/shared/item.model';
+import { ItemViewMode } from '../../../shared/items/item-type-decorator';
+
+@Component({
+ selector: 'ds-related-items',
+ styleUrls: ['./related-items.component.scss'],
+ templateUrl: './related-items.component.html'
+})
+/**
+ * This component is used for displaying relations between items
+ * It expects a list of items to display and a label to put on top
+ */
+export class RelatedItemsComponent {
+ /**
+ * A list of items to display
+ */
+ @Input() items: Item[];
+
+ /**
+ * An i18n label to use as a title for the list (usually describes the relation)
+ */
+ @Input() label: string;
+
+ /**
+ * The view-mode we're currently on
+ * @type {ElementViewMode}
+ */
+ viewMode = ItemViewMode.Element;
+}
diff --git a/src/app/+item-page/simple/related-items/related-items.component.html b/src/app/+item-page/simple/related-items/related-items.component.html
new file mode 100644
index 0000000000..4b284ad63c
--- /dev/null
+++ b/src/app/+item-page/simple/related-items/related-items.component.html
@@ -0,0 +1,5 @@
+ 0" [label]="label">
+
+
+
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 0000000000..e69de29bb2
diff --git a/src/app/+item-page/simple/related-items/related-items.component.spec.ts b/src/app/+item-page/simple/related-items/related-items.component.spec.ts
new file mode 100644
index 0000000000..ef42ab1098
--- /dev/null
+++ b/src/app/+item-page/simple/related-items/related-items.component.spec.ts
@@ -0,0 +1,51 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { RelatedItemsComponent } from './related-items-component';
+import { Item } from '../../../core/shared/item.model';
+import { RemoteData } from '../../../core/data/remote-data';
+import { PaginatedList } from '../../../core/data/paginated-list';
+import { PageInfo } from '../../../core/shared/page-info.model';
+import { By } from '@angular/platform-browser';
+import { createRelationshipsObservable } from '../item-types/shared/item.component.spec';
+import { of as observableOf } from 'rxjs';
+
+const mockItem1: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: [],
+ relationships: createRelationshipsObservable()
+});
+const mockItem2: Item = Object.assign(new Item(), {
+ bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
+ metadata: [],
+ relationships: createRelationshipsObservable()
+});
+const mockItems = [mockItem1, mockItem2];
+
+describe('RelatedItemsComponent', () => {
+ let comp: RelatedItemsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [],
+ declarations: [RelatedItemsComponent],
+ providers: [],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(RelatedItemsComponent, {
+ set: {changeDetection: ChangeDetectionStrategy.Default}
+ }).compileComponents();
+ }));
+
+ beforeEach(async(() => {
+ fixture = TestBed.createComponent(RelatedItemsComponent);
+ comp = fixture.componentInstance;
+ comp.items = mockItems;
+ fixture.detectChanges();
+ }));
+
+ it(`should load ${mockItems.length} item-type-switcher components`, () => {
+ const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher'));
+ expect(fields.length).toBe(mockItems.length);
+ });
+
+});
diff --git a/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts
new file mode 100644
index 0000000000..baf2f0b920
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts
@@ -0,0 +1,4 @@
+export enum MyDSpaceConfigurationValueType {
+ Workspace = 'workspace',
+ Workflow = 'workflow'
+}
diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts
new file mode 100644
index 0000000000..38d6769437
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts
@@ -0,0 +1,259 @@
+import { of as observableOf } from 'rxjs';
+
+import { MyDSpaceConfigurationService } from './my-dspace-configuration.service';
+import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
+import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
+import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
+import { SearchFilter } from '../+search-page/search-filter.model';
+import { ActivatedRouteStub } from '../shared/testing/active-router-stub';
+import { MockRoleService } from '../shared/mocks/mock-role-service';
+import { cold, hot } from 'jasmine-marbles';
+import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type';
+
+describe('MyDSpaceConfigurationService', () => {
+ let service: MyDSpaceConfigurationService;
+ const value1 = 'random value';
+ const prefixFilter = {
+ 'f.namedresourcetype': ['another value'],
+ 'f.dateSubmitted.min': ['2013'],
+ 'f.dateSubmitted.max': ['2018']
+ };
+ const defaults = new PaginatedSearchOptions({
+ pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }),
+ sort: new SortOptions('score', SortDirection.DESC),
+ query: '',
+ scope: ''
+ });
+
+ const backendFilters = [new SearchFilter('f.namedresourcetype', ['another value']), new SearchFilter('f.dateSubmitted', ['[2013 TO 2018]'])];
+
+ const spy = jasmine.createSpyObj('RouteService', {
+ getQueryParameterValue: observableOf(value1),
+ getQueryParamsWithPrefix: observableOf(prefixFilter),
+ getRouteParameterValue: observableOf(''),
+ getRouteDataValue: observableOf({})
+ });
+
+ const activatedRoute: any = new ActivatedRouteStub();
+
+ const roleService: any = new MockRoleService();
+
+ const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', {
+ getQueryByFilterName: observableOf(''),
+ });
+
+ beforeEach(() => {
+ service = new MyDSpaceConfigurationService(roleService, fixedFilterService, spy, activatedRoute);
+ });
+
+ describe('when the scope is called', () => {
+ beforeEach(() => {
+ service.getCurrentScope('');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'scope\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('scope');
+ });
+ });
+
+ describe('when getCurrentConfiguration is called', () => {
+ beforeEach(() => {
+ service.getCurrentConfiguration('');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'configuration\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('configuration');
+ });
+ });
+
+ describe('when getCurrentQuery is called', () => {
+ beforeEach(() => {
+ service.getCurrentQuery('');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'query\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('query');
+ });
+ });
+
+ describe('when getCurrentDSOType is called', () => {
+ beforeEach(() => {
+ service.getCurrentDSOType();
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'dsoType\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('dsoType');
+ });
+ });
+
+ describe('when getCurrentFrontendFilters is called', () => {
+ beforeEach(() => {
+ service.getCurrentFrontendFilters();
+ });
+ it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
+ expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
+ });
+ });
+
+ describe('when getCurrentFilters is called', () => {
+ let parsedValues$;
+ beforeEach(() => {
+ parsedValues$ = service.getCurrentFilters();
+ });
+ it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
+ expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
+ parsedValues$.subscribe((values) => {
+ expect(values).toEqual(backendFilters);
+ });
+ });
+ });
+
+ describe('when getCurrentSort is called', () => {
+ beforeEach(() => {
+ service.getCurrentSort({} as any);
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'sortDirection\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortDirection');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'sortField\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField');
+ });
+ });
+
+ describe('when getCurrentPagination is called', () => {
+ beforeEach(() => {
+ service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any);
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'page\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('page');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'pageSize\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize');
+ });
+ });
+
+ describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => {
+ beforeEach(() => {
+ spyOn(service, 'getCurrentPagination').and.callThrough();
+ spyOn(service, 'getCurrentSort').and.callThrough();
+ spyOn(service, 'getCurrentScope').and.callThrough();
+ spyOn(service, 'getCurrentConfiguration').and.callThrough();
+ spyOn(service, 'getCurrentQuery').and.callThrough();
+ spyOn(service, 'getCurrentDSOType').and.callThrough();
+ spyOn(service, 'getCurrentFilters').and.callThrough();
+ });
+
+ describe('when subscribeToSearchOptions is called', () => {
+ beforeEach(() => {
+ (service as any).subscribeToSearchOptions(defaults)
+ });
+ it('should call all getters it needs, but not call any others', () => {
+ expect(service.getCurrentPagination).not.toHaveBeenCalled();
+ expect(service.getCurrentSort).not.toHaveBeenCalled();
+ expect(service.getCurrentScope).toHaveBeenCalled();
+ expect(service.getCurrentConfiguration).toHaveBeenCalled();
+ expect(service.getCurrentQuery).toHaveBeenCalled();
+ expect(service.getCurrentDSOType).toHaveBeenCalled();
+ expect(service.getCurrentFilters).toHaveBeenCalled();
+ });
+ });
+
+ describe('when subscribeToPaginatedSearchOptions is called', () => {
+ beforeEach(() => {
+ (service as any).subscribeToPaginatedSearchOptions(defaults);
+ });
+ it('should call all getters it needs', () => {
+ expect(service.getCurrentPagination).toHaveBeenCalled();
+ expect(service.getCurrentSort).toHaveBeenCalled();
+ expect(service.getCurrentScope).toHaveBeenCalled();
+ expect(service.getCurrentConfiguration).toHaveBeenCalled();
+ expect(service.getCurrentQuery).toHaveBeenCalled();
+ expect(service.getCurrentDSOType).toHaveBeenCalled();
+ expect(service.getCurrentFilters).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when getAvailableConfigurationTypes is called', () => {
+
+ it('should return properly list when user is submitter', () => {
+ roleService.setSubmitter(true);
+ roleService.setController(false);
+ roleService.setAdmin(false);
+
+ const list$ = service.getAvailableConfigurationTypes();
+
+ expect(list$).toBeObservable(cold('(b|)', {
+ b: [
+ MyDSpaceConfigurationValueType.Workspace
+ ]
+ }));
+ });
+
+ it('should return properly list when user is controller', () => {
+ roleService.setSubmitter(false);
+ roleService.setController(true);
+ roleService.setAdmin(false);
+
+ const list$ = service.getAvailableConfigurationTypes();
+
+ expect(list$).toBeObservable(cold('(b|)', {
+ b: [
+ MyDSpaceConfigurationValueType.Workflow
+ ]
+ }));
+ });
+
+ it('should return properly list when user is admin', () => {
+ roleService.setSubmitter(false);
+ roleService.setController(false);
+ roleService.setAdmin(true);
+
+ const list$ = service.getAvailableConfigurationTypes();
+
+ expect(list$).toBeObservable(cold('(b|)', {
+ b: [
+ MyDSpaceConfigurationValueType.Workflow
+ ]
+ }));
+ });
+
+ it('should return properly list when user is submitter and controller', () => {
+ roleService.setSubmitter(true);
+ roleService.setController(true);
+ roleService.setAdmin(false);
+
+ const list$ = service.getAvailableConfigurationTypes();
+
+ expect(list$).toBeObservable(cold('(b|)', {
+ b: [
+ MyDSpaceConfigurationValueType.Workspace,
+ MyDSpaceConfigurationValueType.Workflow
+ ]
+ }));
+ });
+ });
+
+ describe('when getAvailableConfigurationOptions is called', () => {
+
+ it('should return properly options list', () => {
+ spyOn(service, 'getAvailableConfigurationTypes').and.returnValue(hot('a', {
+ a: [
+ MyDSpaceConfigurationValueType.Workspace,
+ MyDSpaceConfigurationValueType.Workflow
+ ]
+ }));
+
+ const list$ = service.getAvailableConfigurationOptions();
+
+ expect(list$).toBeObservable(cold('(b|)', {
+ b: [
+ {
+ value: MyDSpaceConfigurationValueType.Workspace,
+ label: `mydspace.show.${MyDSpaceConfigurationValueType.Workspace}`
+ },
+ {
+ value: MyDSpaceConfigurationValueType.Workflow,
+ label: `mydspace.show.${MyDSpaceConfigurationValueType.Workflow}`
+ }
+ ]
+ }));
+ });
+ });
+});
diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.ts
new file mode 100644
index 0000000000..705ec897f8
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-configuration.service.ts
@@ -0,0 +1,120 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+import { combineLatest, Observable } from 'rxjs';
+import { first, map } from 'rxjs/operators';
+
+import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type';
+import { RoleService } from '../core/roles/role.service';
+import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model';
+import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service';
+import { RouteService } from '../shared/services/route.service';
+import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
+import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
+import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service';
+
+/**
+ * Service that performs all actions that have to do with the current mydspace configuration
+ */
+@Injectable()
+export class MyDSpaceConfigurationService extends SearchConfigurationService {
+ /**
+ * Default pagination settings
+ */
+ protected defaultPagination = Object.assign(new PaginationComponentOptions(), {
+ id: 'mydspace-page',
+ pageSize: 10,
+ currentPage: 1
+ });
+
+ /**
+ * Default sort settings
+ */
+ protected defaultSort = new SortOptions('dc.date.issued', SortDirection.DESC);
+
+ /**
+ * Default configuration parameter setting
+ */
+ protected defaultConfiguration = 'workspace';
+
+ /**
+ * Default scope setting
+ */
+ protected defaultScope = '';
+
+ /**
+ * Default query setting
+ */
+ protected defaultQuery = '';
+
+ private isAdmin$: Observable;
+ private isController$: Observable;
+ private isSubmitter$: Observable;
+
+ /**
+ * Initialize class
+ *
+ * @param {roleService} roleService
+ * @param {SearchFixedFilterService} fixedFilterService
+ * @param {RouteService} routeService
+ * @param {ActivatedRoute} route
+ */
+ constructor(protected roleService: RoleService,
+ protected fixedFilterService: SearchFixedFilterService,
+ protected routeService: RouteService,
+ protected route: ActivatedRoute) {
+
+ super(routeService, fixedFilterService, route);
+
+ // override parent class initialization
+ this._defaults = null;
+ this.initDefaults();
+
+ this.isSubmitter$ = this.roleService.isSubmitter();
+ this.isController$ = this.roleService.isController();
+ this.isAdmin$ = this.roleService.isAdmin();
+ }
+
+ /**
+ * Returns the list of available configuration depend on the user role
+ *
+ * @return {Observable}
+ * Emits the available configuration list
+ */
+ public getAvailableConfigurationTypes(): Observable {
+ return combineLatest(this.isSubmitter$, this.isController$, this.isAdmin$).pipe(
+ first(),
+ map(([isSubmitter, isController, isAdmin]: [boolean, boolean, boolean]) => {
+ const availableConf: MyDSpaceConfigurationValueType[] = [];
+ if (isSubmitter) {
+ availableConf.push(MyDSpaceConfigurationValueType.Workspace);
+ }
+ if (isController || isAdmin) {
+ availableConf.push(MyDSpaceConfigurationValueType.Workflow);
+ }
+ return availableConf;
+ }));
+ }
+
+ /**
+ * Returns the select options for the available configuration list
+ *
+ * @return {Observable}
+ * Emits the select options list
+ */
+ public getAvailableConfigurationOptions(): Observable {
+ return this.getAvailableConfigurationTypes().pipe(
+ first(),
+ map((availableConfigurationTypes: MyDSpaceConfigurationValueType[]) => {
+ const configurationOptions: SearchConfigurationOption[] = [];
+ availableConfigurationTypes.forEach((type) => {
+ const value = type;
+ const label = `mydspace.show.${value}`;
+ configurationOptions.push({ value, label });
+ });
+ return configurationOptions;
+ })
+ )
+ }
+
+}
diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html
new file mode 100644
index 0000000000..280d694d27
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html
@@ -0,0 +1,15 @@
+
diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss
new file mode 100644
index 0000000000..40a955b349
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss
@@ -0,0 +1,11 @@
+.parent {
+ display: flex;
+}
+
+.upload {
+ flex: auto;
+}
+
+.add {
+ flex: initial;
+}
diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts
new file mode 100644
index 0000000000..012f86f579
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts
@@ -0,0 +1,101 @@
+import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { Store } from '@ngrx/store';
+import { of as observableOf } from 'rxjs';
+import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
+import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to';
+
+import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
+import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
+import { AuthService } from '../../core/auth/auth.service';
+import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
+import { createTestComponent } from '../../shared/testing/utils';
+import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission.component';
+import { AppState } from '../../app.reducer';
+import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
+import { getMockTranslateService } from '../../shared/mocks/mock-translate.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub';
+import { SharedModule } from '../../shared/shared.module';
+import { getMockScrollToService } from '../../shared/mocks/mock-scroll-to-service';
+import { UploaderService } from '../../shared/uploader/uploader.service';
+
+describe('MyDSpaceNewSubmissionComponent test', () => {
+
+ const translateService: any = getMockTranslateService();
+ const store: Store = jasmine.createSpyObj('store', {
+ /* tslint:disable:no-empty */
+ dispatch: {},
+ /* tslint:enable:no-empty */
+ pipe: observableOf(true)
+ });
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ RouterTestingModule,
+ SharedModule,
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })
+ ],
+ declarations: [
+ MyDSpaceNewSubmissionComponent,
+ TestComponent
+ ],
+ providers: [
+ { provide: AuthService, useClass: AuthServiceStub },
+ { provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') },
+ { provide: NotificationsService, useValue: new NotificationsServiceStub() },
+ { provide: ScrollToService, useValue: getMockScrollToService() },
+ { provide: Store, useValue: store },
+ { provide: TranslateService, useValue: translateService },
+ ChangeDetectorRef,
+ MyDSpaceNewSubmissionComponent,
+ UploaderService
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ describe('', () => {
+ let testComp: TestComponent;
+ let testFixture: ComponentFixture;
+
+ // synchronous beforeEach
+ beforeEach(() => {
+ const html = `
+ `;
+
+ testFixture = createTestComponent(html, TestComponent) as ComponentFixture;
+ testComp = testFixture.componentInstance;
+ });
+
+ afterEach(() => {
+ testFixture.destroy();
+ });
+
+ it('should create MyDSpaceNewSubmissionComponent', inject([MyDSpaceNewSubmissionComponent], (app: MyDSpaceNewSubmissionComponent) => {
+
+ expect(app).toBeDefined();
+
+ }));
+ });
+
+});
+
+// declare a test component
+@Component({
+ selector: 'ds-test-cmp',
+ template: ``
+})
+class TestComponent {
+
+ reload = (event) => {
+ return;
+ }
+}
diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts
new file mode 100644
index 0000000000..938a1ec899
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts
@@ -0,0 +1,118 @@
+import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+import { first } from 'rxjs/operators';
+import { Store } from '@ngrx/store';
+import { TranslateService } from '@ngx-translate/core';
+
+import { SubmissionState } from '../../submission/submission.reducers';
+import { AuthService } from '../../core/auth/auth.service';
+import { MyDSpaceResult } from '../my-dspace-result.model';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
+import { UploaderOptions } from '../../shared/uploader/uploader-options.model';
+import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
+import { NotificationType } from '../../shared/notifications/models/notification-type';
+import { hasValue } from '../../shared/empty.util';
+
+/**
+ * This component represents the whole mydspace page header
+ */
+@Component({
+ selector: 'ds-my-dspace-new-submission',
+ styleUrls: ['./my-dspace-new-submission.component.scss'],
+ templateUrl: './my-dspace-new-submission.component.html'
+})
+export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit {
+ @Output() uploadEnd = new EventEmitter>>();
+
+ /**
+ * The UploaderOptions object
+ */
+ public uploadFilesOptions: UploaderOptions = {
+ url: '',
+ authToken: null,
+ disableMultipart: false,
+ itemAlias: null
+ };
+
+ /**
+ * Subscription to unsubscribe from
+ */
+ private sub: Subscription;
+
+ /**
+ * Initialize instance variables
+ *
+ * @param {AuthService} authService
+ * @param {ChangeDetectorRef} changeDetectorRef
+ * @param {HALEndpointService} halService
+ * @param {NotificationsService} notificationsService
+ * @param {Store} store
+ * @param {TranslateService} translate
+ */
+ constructor(private authService: AuthService,
+ private changeDetectorRef: ChangeDetectorRef,
+ private halService: HALEndpointService,
+ private notificationsService: NotificationsService,
+ private store: Store,
+ private translate: TranslateService) {
+ }
+
+ /**
+ * Initialize url and Bearer token
+ */
+ ngOnInit() {
+ this.sub = this.halService.getEndpoint('workspaceitems').pipe(first()).subscribe((url) => {
+ this.uploadFilesOptions.url = url;
+ this.uploadFilesOptions.authToken = this.authService.buildAuthHeader();
+ this.changeDetectorRef.detectChanges();
+ }
+ );
+ }
+
+ /**
+ * Method called when file upload is completed to notify upload status
+ */
+ public onCompleteItem(res) {
+ if (res && res._embedded && res._embedded.workspaceitems && res._embedded.workspaceitems.length > 0) {
+ const workspaceitems = res._embedded.workspaceitems;
+ this.uploadEnd.emit(workspaceitems);
+
+ if (workspaceitems.length === 1) {
+ const options = new NotificationOptions();
+ options.timeOut = 0;
+ const link = '/workspaceitems/' + workspaceitems[0].id + '/edit';
+ this.notificationsService.notificationWithAnchor(
+ NotificationType.Success,
+ options,
+ link,
+ 'mydspace.general.text-here',
+ 'mydspace.upload.upload-successful',
+ 'here');
+ } else if (workspaceitems.length > 1) {
+ this.notificationsService.success(null, this.translate.get('mydspace.upload.upload-multiple-successful', {qty: workspaceitems.length}));
+ }
+
+ } else {
+ this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed'));
+ }
+ }
+
+ /**
+ * Method called on file upload error
+ */
+ public onUploadError() {
+ this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed'));
+ }
+
+ /**
+ * Unsubscribe from the subscription
+ */
+ ngOnDestroy(): void {
+ if (hasValue(this.sub)) {
+ this.sub.unsubscribe();
+ }
+ }
+}
diff --git a/src/app/+my-dspace-page/my-dspace-page-routing.module.ts b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts
new file mode 100644
index 0000000000..d70a007e3a
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts
@@ -0,0 +1,25 @@
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+import { MyDSpacePageComponent } from './my-dspace-page.component';
+import { MyDSpaceGuard } from './my-dspace.guard';
+
+@NgModule({
+ imports: [
+ RouterModule.forChild([
+ {
+ path: '',
+ component: MyDSpacePageComponent,
+ data: { title: 'mydspace.title' },
+ canActivate: [
+ MyDSpaceGuard
+ ]
+ }
+ ])
+ ]
+})
+/**
+ * This module defines the default component to load when navigating to the mydspace page path.
+ */
+export class MyDspacePageRoutingModule {
+}
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.html b/src/app/+my-dspace-page/my-dspace-page.component.html
new file mode 100644
index 0000000000..4c691028fc
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-page.component.html
@@ -0,0 +1,48 @@
+
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.scss b/src/app/+my-dspace-page/my-dspace-page.component.scss
new file mode 100644
index 0000000000..86c589bf66
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-page.component.scss
@@ -0,0 +1 @@
+@import '../+search-page/search-page.component.scss';
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts
new file mode 100644
index 0000000000..9658814a6a
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts
@@ -0,0 +1,204 @@
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { ActivatedRoute } from '@angular/router';
+import { By } from '@angular/platform-browser';
+import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { Store } from '@ngrx/store';
+import { TranslateModule } from '@ngx-translate/core';
+import { cold } from 'jasmine-marbles';
+import { of as observableOf } from 'rxjs';
+
+import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
+import { CommunityDataService } from '../core/data/community-data.service';
+import { HostWindowService } from '../shared/host-window.service';
+import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
+import { RemoteData } from '../core/data/remote-data';
+import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.component';
+import { RouteService } from '../shared/services/route.service';
+import { routeServiceStub } from '../shared/testing/route-service-stub';
+import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub';
+import { SearchService } from '../+search-page/search-service/search.service';
+import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service';
+import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
+import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service';
+import { SearchFilterService } from '../+search-page/search-filters/search-filter/search-filter.service';
+import { RoleDirective } from '../shared/roles/role.directive';
+import { RoleService } from '../core/roles/role.service';
+import { MockRoleService } from '../shared/mocks/mock-role-service';
+import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service';
+
+describe('MyDSpacePageComponent', () => {
+ let comp: MyDSpacePageComponent;
+ let fixture: ComponentFixture;
+ let searchServiceObject: SearchService;
+ let searchConfigurationServiceObject: SearchConfigurationService;
+ const store: Store = jasmine.createSpyObj('store', {
+ /* tslint:disable:no-empty */
+ dispatch: {},
+ /* tslint:enable:no-empty */
+ select: observableOf(true)
+ });
+ const pagination: PaginationComponentOptions = new PaginationComponentOptions();
+ pagination.id = 'mydspace-results-pagination';
+ pagination.currentPage = 1;
+ pagination.pageSize = 10;
+ const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
+ const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data']));
+ const searchServiceStub = jasmine.createSpyObj('SearchService', {
+ search: mockResults,
+ getSearchLink: '/mydspace',
+ getScopes: observableOf(['test-scope']),
+ setServiceOptions: {}
+ });
+ const configurationParam = 'default';
+ const queryParam = 'test query';
+ const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
+ const paginatedSearchOptions = new PaginatedSearchOptions({
+ configuration: configurationParam,
+ query: queryParam,
+ scope: scopeParam,
+ pagination,
+ sort
+ });
+ const activatedRouteStub = {
+ snapshot: {
+ queryParamMap: new Map([
+ ['query', queryParam],
+ ['scope', scopeParam]
+ ])
+ },
+ queryParams: observableOf({
+ query: queryParam,
+ scope: scopeParam
+ })
+ };
+ const sidebarService = {
+ isCollapsed: observableOf(true),
+ collapse: () => this.isCollapsed = observableOf(true),
+ expand: () => this.isCollapsed = observableOf(false)
+ };
+ const mockFixedFilterService: SearchFixedFilterService = {
+ getQueryByFilterName: (filter: string) => {
+ return observableOf(undefined)
+ }
+ } as SearchFixedFilterService;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()],
+ declarations: [MyDSpacePageComponent, RoleDirective],
+ providers: [
+ { provide: SearchService, useValue: searchServiceStub },
+ {
+ provide: CommunityDataService,
+ useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll'])
+ },
+ { provide: ActivatedRoute, useValue: activatedRouteStub },
+ { provide: RouteService, useValue: routeServiceStub },
+ {
+ provide: Store, useValue: store
+ },
+ {
+ provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
+ {
+ isXs: observableOf(true),
+ isSm: observableOf(false),
+ isXsOrSm: observableOf(true)
+ })
+ },
+ {
+ provide: SearchSidebarService,
+ useValue: sidebarService
+ },
+ {
+ provide: SearchFilterService,
+ useValue: {}
+ }, {
+ provide: SEARCH_CONFIG_SERVICE,
+ useValue: new SearchConfigurationServiceStub()
+ },
+ {
+ provide: RoleService,
+ useValue: new MockRoleService()
+ },
+ {
+ provide: SearchFixedFilterService,
+ useValue: mockFixedFilterService
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(MyDSpacePageComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default }
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MyDSpacePageComponent);
+ comp = fixture.componentInstance; // SearchPageComponent test instance
+ fixture.detectChanges();
+ searchServiceObject = (comp as any).service;
+ searchConfigurationServiceObject = (comp as any).searchConfigService;
+ });
+
+ afterEach(() => {
+ comp = null;
+ searchServiceObject = null;
+ searchConfigurationServiceObject = null;
+ });
+
+ it('should get the scope and query from the route parameters', () => {
+
+ searchConfigurationServiceObject.paginatedSearchOptions.next(paginatedSearchOptions);
+ expect(comp.searchOptions$).toBeObservable(cold('b', {
+ b: paginatedSearchOptions
+ }));
+
+ });
+
+ describe('when the open sidebar button is clicked in mobile view', () => {
+
+ beforeEach(() => {
+ spyOn(comp, 'openSidebar');
+ const openSidebarButton = fixture.debugElement.query(By.css('.open-sidebar'));
+ openSidebarButton.triggerEventHandler('click', null);
+ });
+
+ it('should trigger the openSidebar function', () => {
+ expect(comp.openSidebar).toHaveBeenCalled();
+ });
+
+ });
+
+ describe('when sidebarCollapsed is true in mobile view', () => {
+ let menu: HTMLElement;
+
+ beforeEach(() => {
+ menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
+ comp.isSidebarCollapsed = () => observableOf(true);
+ fixture.detectChanges();
+ });
+
+ it('should close the sidebar', () => {
+ expect(menu.classList).not.toContain('active');
+ });
+
+ });
+
+ describe('when sidebarCollapsed is false in mobile view', () => {
+ let menu: HTMLElement;
+
+ beforeEach(() => {
+ menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
+ comp.isSidebarCollapsed = () => observableOf(false);
+ fixture.detectChanges();
+ });
+
+ it('should open the menu', () => {
+ expect(menu.classList).toContain('active');
+ });
+
+ });
+});
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.ts b/src/app/+my-dspace-page/my-dspace-page.component.ts
new file mode 100644
index 0000000000..251bf50bd1
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-page.component.ts
@@ -0,0 +1,168 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ Inject,
+ InjectionToken,
+ Input,
+ OnInit
+} from '@angular/core';
+
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { switchMap, tap, } from 'rxjs/operators';
+
+import { PaginatedList } from '../core/data/paginated-list';
+import { RemoteData } from '../core/data/remote-data';
+import { DSpaceObject } from '../core/shared/dspace-object.model';
+import { pushInOut } from '../shared/animations/push';
+import { HostWindowService } from '../shared/host-window.service';
+import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
+import { SearchService } from '../+search-page/search-service/search.service';
+import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service';
+import { hasValue } from '../shared/empty.util';
+import { getSucceededRemoteData } from '../core/shared/operators';
+import { MyDSpaceResult } from './my-dspace-result.model';
+import { MyDSpaceResponseParsingService } from '../core/data/mydspace-response-parsing.service';
+import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model';
+import { RoleType } from '../core/roles/role-types';
+import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service';
+import { MyDSpaceConfigurationService } from './my-dspace-configuration.service';
+import { ViewMode } from '../core/shared/view-mode.model';
+import { MyDSpaceRequest } from '../core/data/request.models';
+
+export const MYDSPACE_ROUTE = '/mydspace';
+export const SEARCH_CONFIG_SERVICE: InjectionToken = new InjectionToken('searchConfigurationService');
+
+/**
+ * This component represents the whole mydspace page
+ */
+@Component({
+ selector: 'ds-my-dspace-page',
+ styleUrls: ['./my-dspace-page.component.scss'],
+ templateUrl: './my-dspace-page.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ animations: [pushInOut],
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: MyDSpaceConfigurationService
+ }
+ ]
+})
+export class MyDSpacePageComponent implements OnInit {
+
+ /**
+ * True when the search component should show results on the current page
+ */
+ @Input() inPlaceSearch = true;
+
+ /**
+ * The list of available configuration options
+ */
+ configurationList$: Observable;
+
+ /**
+ * The current search results
+ */
+ resultsRD$: BehaviorSubject>>> = new BehaviorSubject(null);
+
+ /**
+ * The current paginated search options
+ */
+ searchOptions$: Observable;
+
+ /**
+ * The current relevant scopes
+ */
+ scopeListRD$: Observable;
+
+ /**
+ * Emits true if were on a small screen
+ */
+ isXsOrSm$: Observable;
+
+ /**
+ * Subscription to unsubscribe from
+ */
+ sub: Subscription;
+
+ /**
+ * Variable for enumeration RoleType
+ */
+ roleTypeEnum = RoleType;
+
+ /**
+ * List of available view mode
+ */
+ viewModeList = [ViewMode.List, ViewMode.Detail];
+
+ constructor(private service: SearchService,
+ private sidebarService: SearchSidebarService,
+ private windowService: HostWindowService,
+ @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: MyDSpaceConfigurationService) {
+ this.isXsOrSm$ = this.windowService.isXsOrSm();
+ this.service.setServiceOptions(MyDSpaceResponseParsingService, MyDSpaceRequest);
+ }
+
+ /**
+ * Initialize available configuration list
+ *
+ * Listening to changes in the paginated search options
+ * If something changes, update the search results
+ *
+ * Listen to changes in the scope
+ * If something changes, update the list of scopes for the dropdown
+ */
+ ngOnInit(): void {
+ this.configurationList$ = this.searchConfigService.getAvailableConfigurationOptions();
+ this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
+
+ this.sub = this.searchOptions$.pipe(
+ tap(() => this.resultsRD$.next(null)),
+ switchMap((options: PaginatedSearchOptions) => this.service.search(options).pipe(getSucceededRemoteData())))
+ .subscribe((results) => {
+ this.resultsRD$.next(results);
+ });
+ this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
+ switchMap((scopeId) => this.service.getScopes(scopeId))
+ );
+
+ }
+
+ /**
+ * Set the sidebar to a collapsed state
+ */
+ public closeSidebar(): void {
+ this.sidebarService.collapse()
+ }
+
+ /**
+ * Set the sidebar to an expanded state
+ */
+ public openSidebar(): void {
+ this.sidebarService.expand();
+ }
+
+ /**
+ * Check if the sidebar is collapsed
+ * @returns {Observable} emits true if the sidebar is currently collapsed, false if it is expanded
+ */
+ public isSidebarCollapsed(): Observable {
+ return this.sidebarService.isCollapsed;
+ }
+
+ /**
+ * @returns {string} The base path to the search page
+ */
+ public getSearchLink(): string {
+ return this.service.getSearchLink();
+ }
+
+ /**
+ * Unsubscribe from the subscription
+ */
+ ngOnDestroy(): void {
+ if (hasValue(this.sub)) {
+ this.sub.unsubscribe();
+ }
+ }
+}
diff --git a/src/app/+my-dspace-page/my-dspace-page.module.ts b/src/app/+my-dspace-page/my-dspace-page.module.ts
new file mode 100644
index 0000000000..4b8cf37b7a
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-page.module.ts
@@ -0,0 +1,69 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { SharedModule } from '../shared/shared.module';
+
+import { MyDspacePageRoutingModule } from './my-dspace-page-routing.module';
+import { MyDSpacePageComponent } from './my-dspace-page.component';
+import { SearchPageModule } from '../+search-page/search-page.module';
+import { MyDSpaceResultsComponent } from './my-dspace-results/my-dspace-results.component';
+import { WorkspaceitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component';
+import { ItemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component';
+import { WorkflowitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component';
+import { ClaimedMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component';
+import { PoolMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component';
+import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission/my-dspace-new-submission.component';
+import { ItemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component';
+import { WorkspaceitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component';
+import { WorkflowitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component';
+import { ClaimedMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component';
+import { PoolMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-lement.component';
+import { MyDSpaceGuard } from './my-dspace.guard';
+import { MyDSpaceConfigurationService } from './my-dspace-configuration.service';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ SharedModule,
+ MyDspacePageRoutingModule,
+ SearchPageModule
+ ],
+ declarations: [
+ MyDSpacePageComponent,
+ MyDSpaceResultsComponent,
+ ItemMyDSpaceResultListElementComponent,
+ WorkspaceitemMyDSpaceResultListElementComponent,
+ WorkflowitemMyDSpaceResultListElementComponent,
+ ClaimedMyDSpaceResultListElementComponent,
+ PoolMyDSpaceResultListElementComponent,
+ ItemMyDSpaceResultDetailElementComponent,
+ WorkspaceitemMyDSpaceResultDetailElementComponent,
+ WorkflowitemMyDSpaceResultDetailElementComponent,
+ ClaimedMyDSpaceResultDetailElementComponent,
+ PoolMyDSpaceResultDetailElementComponent,
+ MyDSpaceNewSubmissionComponent
+ ],
+ providers: [
+ MyDSpaceGuard,
+ MyDSpaceConfigurationService
+ ],
+ entryComponents: [
+ ItemMyDSpaceResultListElementComponent,
+ WorkspaceitemMyDSpaceResultListElementComponent,
+ WorkflowitemMyDSpaceResultListElementComponent,
+ ClaimedMyDSpaceResultListElementComponent,
+ PoolMyDSpaceResultListElementComponent,
+ ItemMyDSpaceResultDetailElementComponent,
+ WorkspaceitemMyDSpaceResultDetailElementComponent,
+ WorkflowitemMyDSpaceResultDetailElementComponent,
+ ClaimedMyDSpaceResultDetailElementComponent,
+ PoolMyDSpaceResultDetailElementComponent
+ ]
+})
+
+/**
+ * This module handles all components that are necessary for the mydspace page
+ */
+export class MyDSpacePageModule {
+
+}
diff --git a/src/app/+my-dspace-page/my-dspace-result.model.ts b/src/app/+my-dspace-page/my-dspace-result.model.ts
new file mode 100644
index 0000000000..d300ed0bc8
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-result.model.ts
@@ -0,0 +1,19 @@
+import { DSpaceObject } from '../core/shared/dspace-object.model';
+import { MetadataMap } from '../core/shared/metadata.models';
+import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
+
+/**
+ * Represents a search result object of a certain () DSpaceObject
+ */
+export class MyDSpaceResult implements ListableObject {
+ /**
+ * The DSpaceObject that was found
+ */
+ indexableObject: T;
+
+ /**
+ * The metadata that was used to find this item, hithighlighted
+ */
+ hitHighlights: MetadataMap;
+
+}
diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html
new file mode 100644
index 0000000000..132a0d2204
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html
@@ -0,0 +1,12 @@
+
0" @fadeIn>
+
+
+
+
+
+{{'mydspace.results.no-results' | translate}}
diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts
new file mode 100644
index 0000000000..67625706a6
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts
@@ -0,0 +1,58 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+
+import { TranslateModule } from '@ngx-translate/core';
+import { QueryParamsDirectiveStub } from '../../shared/testing/query-params-directive-stub';
+import { MyDSpaceResultsComponent } from './my-dspace-results.component';
+
+describe('MyDSpaceResultsComponent', () => {
+ let comp: MyDSpaceResultsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), NoopAnimationsModule],
+ declarations: [
+ MyDSpaceResultsComponent,
+ QueryParamsDirectiveStub],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MyDSpaceResultsComponent);
+ comp = fixture.componentInstance; // MyDSpaceResultsComponent test instance
+ });
+
+ it('should display results when results are not empty', () => {
+ (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } };
+ (comp as any).searchConfig = {};
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).not.toBeNull();
+ });
+
+ it('should not display link when results are not empty', () => {
+ (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } };
+ (comp as any).searchConfig = {};
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('a'))).toBeNull();
+ });
+
+ it('should display error message if error is != 400', () => {
+ (comp as any).searchResults = { hasFailed: true, error: { statusCode: 500 } };
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('ds-error'))).not.toBeNull();
+ });
+
+ it('should display a message if search result is empty', () => {
+ (comp as any).searchResults = { payload: { page: { length: 0 } } };
+ (comp as any).searchConfig = { query: 'foobar' };
+ fixture.detectChanges();
+
+ const linkDes = fixture.debugElement.queryAll(By.css('text-muted'));
+
+ expect(linkDes).toBeDefined()
+ });
+});
diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts
new file mode 100644
index 0000000000..3a16def9c1
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts
@@ -0,0 +1,51 @@
+import { Component, Input } from '@angular/core';
+
+import { RemoteData } from '../../core/data/remote-data';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { fadeIn, fadeInOut } from '../../shared/animations/fade';
+import { MyDSpaceResult } from '../my-dspace-result.model';
+import { SearchOptions } from '../../+search-page/search-options.model';
+import { PaginatedList } from '../../core/data/paginated-list';
+import { ViewMode } from '../../core/shared/view-mode.model';
+import { isEmpty } from '../../shared/empty.util';
+
+/**
+ * Component that represents all results for mydspace page
+ */
+@Component({
+ selector: 'ds-my-dspace-results',
+ templateUrl: './my-dspace-results.component.html',
+ animations: [
+ fadeIn,
+ fadeInOut
+ ]
+})
+export class MyDSpaceResultsComponent {
+
+ /**
+ * The actual search result objects
+ */
+ @Input() searchResults: RemoteData>>;
+
+ /**
+ * The current configuration of the search
+ */
+ @Input() searchConfig: SearchOptions;
+
+ /**
+ * The current view mode for the search results
+ */
+ @Input() viewMode: ViewMode;
+
+ /**
+ * A boolean representing if search results entry are separated by a line
+ */
+ hasBorder = true;
+
+ /**
+ * Check if mydspace search results are loading
+ */
+ isLoading() {
+ return !this.searchResults || isEmpty(this.searchResults) || this.searchResults.isLoading;
+ }
+}
diff --git a/src/app/+my-dspace-page/my-dspace.guard.ts b/src/app/+my-dspace-page/my-dspace.guard.ts
new file mode 100644
index 0000000000..9cb9aff485
--- /dev/null
+++ b/src/app/+my-dspace-page/my-dspace.guard.ts
@@ -0,0 +1,57 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, CanActivate, NavigationExtras, Router, RouterStateSnapshot } from '@angular/router';
+
+import { Observable } from 'rxjs';
+import { first, map } from 'rxjs/operators';
+import { isEmpty } from '../shared/empty.util';
+import { MYDSPACE_ROUTE } from './my-dspace-page.component';
+import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type';
+import { MyDSpaceConfigurationService } from './my-dspace-configuration.service';
+
+/**
+ * Prevent unauthorized activating and loading of mydspace configuration
+ * @class MyDSpaceGuard
+ */
+@Injectable()
+export class MyDSpaceGuard implements CanActivate {
+
+ /**
+ * @constructor
+ */
+ constructor(private configurationService: MyDSpaceConfigurationService, private router: Router) {
+ }
+
+ /**
+ * True when configuration is valid
+ * @method canActivate
+ */
+ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return this.configurationService.getAvailableConfigurationTypes().pipe(
+ first(),
+ map((configurationList) => this.validateConfigurationParam(route.queryParamMap.get('configuration'), configurationList)));
+ }
+
+ /**
+ * Check if the given configuration is present in the list of those available
+ *
+ * @param configuration
+ * the configuration to validate
+ * @param configurationList
+ * the list of available configuration
+ *
+ */
+ private validateConfigurationParam(configuration: string, configurationList: MyDSpaceConfigurationValueType[]): boolean {
+ const configurationDefault: string = configurationList[0];
+ if (isEmpty(configuration) || !configurationList.includes(configuration as MyDSpaceConfigurationValueType)) {
+ // If configuration param is empty or is not included in available configurations redirect to a default configuration value
+ const navigationExtras: NavigationExtras = {
+ queryParams: {configuration: configurationDefault}
+ };
+
+ this.router.navigate([MYDSPACE_ROUTE], navigationExtras);
+ return false;
+ } else {
+ return true;
+ }
+ }
+}
diff --git a/src/app/+search-page/filtered-search-page.component.spec.ts b/src/app/+search-page/filtered-search-page.component.spec.ts
new file mode 100644
index 0000000000..5c49767ed2
--- /dev/null
+++ b/src/app/+search-page/filtered-search-page.component.spec.ts
@@ -0,0 +1,37 @@
+import { FilteredSearchPageComponent } from './filtered-search-page.component';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { configureSearchComponentTestingModule } from './search-page.component.spec';
+import { SearchConfigurationService } from './search-service/search-configuration.service';
+
+describe('FilteredSearchPageComponent', () => {
+ let comp: FilteredSearchPageComponent;
+ let fixture: ComponentFixture;
+ let searchConfigService: SearchConfigurationService;
+
+ beforeEach(async(() => {
+ configureSearchComponentTestingModule(FilteredSearchPageComponent);
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(FilteredSearchPageComponent);
+ comp = fixture.componentInstance;
+ searchConfigService = (comp as any).searchConfigService;
+ fixture.detectChanges();
+ });
+
+ describe('when fixedFilterQuery is defined', () => {
+ const fixedFilterQuery = 'fixedFilterQuery';
+
+ beforeEach(() => {
+ spyOn(searchConfigService, 'updateFixedFilter').and.callThrough();
+ comp.fixedFilterQuery = fixedFilterQuery;
+ comp.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should update the paginated search options', () => {
+ expect(searchConfigService.updateFixedFilter).toHaveBeenCalledWith(fixedFilterQuery);
+ });
+ });
+
+});
diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts
new file mode 100644
index 0000000000..d577c2c44c
--- /dev/null
+++ b/src/app/+search-page/filtered-search-page.component.ts
@@ -0,0 +1,58 @@
+import { HostWindowService } from '../shared/host-window.service';
+import { SearchService } from './search-service/search.service';
+import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
+import { SearchPageComponent } from './search-page.component';
+import { ChangeDetectionStrategy, Component, Inject, Input } from '@angular/core';
+import { pushInOut } from '../shared/animations/push';
+import { RouteService } from '../shared/services/route.service';
+import { SearchConfigurationService } from './search-service/search-configuration.service';
+import { Observable } from 'rxjs';
+import { PaginatedSearchOptions } from './paginated-search-options.model';
+import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
+
+/**
+ * This component renders a simple item page.
+ * The route parameter 'id' is used to request the item it represents.
+ * All fields of the item that should be displayed, are defined in its template.
+ */
+@Component({selector: 'ds-filtered-search-page',
+ styleUrls: ['./search-page.component.scss'],
+ templateUrl: './search-page.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ animations: [pushInOut],
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: SearchConfigurationService
+ }
+ ]
+})
+
+export class FilteredSearchPageComponent extends SearchPageComponent {
+
+ /**
+ * The actual query for the fixed filter.
+ * If empty, the query will be determined by the route parameter called 'filter'
+ */
+ @Input() fixedFilterQuery: string;
+
+ constructor(protected service: SearchService,
+ protected sidebarService: SearchSidebarService,
+ protected windowService: HostWindowService,
+ @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
+ protected routeService: RouteService) {
+ super(service, sidebarService, windowService, searchConfigService, routeService);
+ }
+
+ /**
+ * Get the current paginated search options after updating the fixed filter using the fixedFilterQuery input
+ * This is to make sure the fixed filter is included in the paginated search options, as it is not part of any
+ * query or route parameters
+ * @returns {Observable}
+ */
+ protected getSearchOptions(): Observable {
+ this.searchConfigService.updateFixedFilter(this.fixedFilterQuery);
+ return this.searchConfigService.paginatedSearchOptions;
+ }
+
+}
diff --git a/src/app/+search-page/filtered-search-page.guard.ts b/src/app/+search-page/filtered-search-page.guard.ts
new file mode 100644
index 0000000000..39fbb48c67
--- /dev/null
+++ b/src/app/+search-page/filtered-search-page.guard.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@angular/core';
+import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
+import { Observable } from 'rxjs';
+
+@Injectable()
+/**
+ * Assemble the correct i18n key for the filtered search page's title depending on the current route's filter parameter
+ * and title data.
+ * The format of the key will be "{title}{filter}.title" with:
+ * - title: The prefix of the key stored in route.data
+ * - filter: The current filter stored in route.params
+ */
+export class FilteredSearchPageGuard implements CanActivate {
+ canActivate(
+ route: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot): Observable | Promise | boolean {
+ const filter = route.params.filter;
+
+ const newTitle = route.data.title + filter + '.title';
+
+ route.data = { title: newTitle };
+ return true;
+ }
+}
diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts
index 46f14c042d..32f3217b54 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 8f4d93b0df..45cd0b8f09 100644
--- a/src/app/+search-page/paginated-search-options.model.ts
+++ b/src/app/+search-page/paginated-search-options.model.ts
@@ -12,7 +12,7 @@ export class PaginatedSearchOptions extends SearchOptions {
pagination?: PaginationComponentOptions;
sort?: SortOptions;
- constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], pagination?: PaginationComponentOptions, sort?: SortOptions}) {
+ constructor(options: {configuration?: string, scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions}) {
super(options);
this.pagination = options.pagination;
this.sort = options.sort;
diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html
new file mode 100644
index 0000000000..76cdc6c8f5
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html
@@ -0,0 +1,27 @@
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss
new file mode 100644
index 0000000000..33e354f2d8
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss
@@ -0,0 +1,23 @@
+@import '../../../../../styles/variables.scss';
+@import '../../../../../styles/mixins.scss';
+
+.filters {
+ a {
+ color: $body-color;
+ &:hover, &focus {
+ text-decoration: none;
+ }
+ span.badge {
+ vertical-align: text-top;
+ }
+ }
+ .toggle-more-filters a {
+ color: $link-color;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+}
+::ng-deep em {
+ font-weight: bold;
+ font-style: normal;
+}
diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts
new file mode 100644
index 0000000000..83131e1344
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts
@@ -0,0 +1,36 @@
+import { Component, OnInit } from '@angular/core';
+
+import { FilterType } from '../../../search-service/filter-type.model';
+import { facetLoad, SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component';
+import { renderFacetFor } from '../search-filter-type-decorator';
+import { FacetValue } from '../../../search-service/facet-value.model';
+
+@Component({
+ selector: 'ds-search-authority-filter',
+ styleUrls: ['./search-authority-filter.component.scss'],
+ templateUrl: './search-authority-filter.component.html',
+ animations: [facetLoad]
+})
+
+/**
+ * Component that represents an authority facet for a specific filter configuration
+ */
+@renderFacetFor(FilterType.authority)
+export class SearchAuthorityFilterComponent extends SearchFacetFilterComponent implements OnInit {
+
+ /**
+ * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved
+ * Retrieve facet value from search link
+ */
+ protected getFacetValue(facet: FacetValue): string {
+ const search = facet.search;
+ const hashes = search.slice(search.indexOf('?') + 1).split('&');
+ const params = {};
+ hashes.map((hash) => {
+ const [key, val] = hash.split('=');
+ params[key] = decodeURIComponent(val)
+ });
+
+ return params[this.filterConfig.paramName];
+ }
+}
diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
index 968bf9e420..cc39b80db8 100644
--- a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
@@ -1,9 +1,9 @@
-
+
-
+
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 f1dbedfb40..245c0e3ddb 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
;
- 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 016ebf62a3..1fccee3736 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;
+ @Input() selectedValues$: Observable;
+
+ /**
+ * True when the search component should show results on the current page
+ */
+ @Input() inPlaceSearch;
/**
* Emits true when this option should be visible and false when it should be invisible
@@ -71,13 +77,16 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy {
* Checks if a value for this filter is currently active
*/
private isChecked(): Observable {
- 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 b485fe0fd0..8e8ad9b4e3 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 @@
- {{filterValue.value}}
+ {{filterValue.label}}
{{filterValue.count}}
-
\ No newline at end of file
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.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 218730263b..d3264214ed 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 67d31293b0..54d5d535df 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 ba43bae100..5657bd224e 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">
- {{selectedValue}}
-
\ No newline at end of file
+ {{selectedValue.label}}
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.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 545ba1d66b..01defb9893 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;
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 23ad3eccba..78dde92c2b 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;
+ @Input() selectedValues$: Observable;
+
+ /**
+ * 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 6369a7691e..6720b30681 100644
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts
@@ -2,7 +2,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 cb3d4730b4..5d8b51de96 100644
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts
@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
+import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { FilterType } from '../../../search-service/filter-type.model';
import { FacetValue } from '../../../search-service/facet-value.model';
@@ -17,7 +17,9 @@ import { Router } from '@angular/router';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { SearchFacetFilterComponent } from './search-facet-filter.component';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
-import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
+import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub';
+import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
+import { tap } from 'rxjs/operators';
describe('SearchFacetFilterComponent', () => {
let comp: SearchFacetFilterComponent;
@@ -35,14 +37,17 @@ describe('SearchFacetFilterComponent', () => {
});
const values: FacetValue[] = [
{
+ label: value1,
value: value1,
count: 52,
search: ''
}, {
+ label: value2,
value: value2,
count: 20,
search: ''
}, {
+ label: value3,
value: value3,
count: 5,
search: ''
@@ -65,8 +70,9 @@ describe('SearchFacetFilterComponent', () => {
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{ provide: FILTER_CONFIG, useValue: new SearchFilterConfig() },
- { provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} },
- { provide: SearchConfigurationService, useValue: {searchOptions: observableOf({})} },
+ { provide: RemoteDataBuildService, useValue: { aggregate: () => observableOf({}) } },
+ { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
+ { provide: IN_PLACE_SEARCH, useValue: false },
{
provide: SearchFilterService, useValue: {
getSelectedValuesForFilter: () => observableOf(selectedValues),
@@ -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 367947a377..772240eb0b 100644
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts
@@ -6,7 +6,7 @@ import {
Subject,
Subscription
} from 'rxjs';
-import { switchMap, distinctUntilChanged, map, take } from 'rxjs/operators';
+import { switchMap, distinctUntilChanged, map, take, flatMap, tap } from 'rxjs/operators';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
@@ -18,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;
- private collapseNextUpdate = true;
+ selectedValues$: Observable;
+ 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>>) => {
+ this.subs.push(this.rdbs.aggregate(filterValues).pipe(
+ tap((rd: RemoteData>>) => {
+ this.selectedValues$ = this.filterService.getSelectedValuesForFilter(this.filterConfig).pipe(
+ map((selectedValues) => {
+ return selectedValues.map((value: string) => {
+ const fValue = [].concat(...rd.payload.map((page) => page.page)).find((facetValue: FacetValue) => facetValue.value === value);
+ if (hasValue(fValue)) {
+ return fValue;
+ }
+ return Object.assign(new FacetValue(), { label: value, value: value });
+ });
+ })
+ );
+ })
+ ).subscribe((rd: RemoteData>>) => {
this.animationState = 'ready';
this.filterValues$.next(rd);
+
}));
this.subs.push(newValues$.pipe(take(1)).subscribe((rd) => {
this.isLastPage$.next(hasNoValue(rd.payload.next))
@@ -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 5c4db44d24..a1758d7339 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.html
@@ -2,6 +2,6 @@
{{'search.filters.filter.' + filter.name + '.head'| translate}}
-
+
-
\ No newline at end of file
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts
index 30ef349675..23c4ab3b53 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts
@@ -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 14ba8f0b76..bfe9f3be63 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts
@@ -1,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
;
- 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 187bcd50d0..7102c8c9bc 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts
@@ -1,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 19239d899c..e317a27698 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts
@@ -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 = 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 bed4b1777f..4b12417084 100644
--- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts
@@ -1,6 +1,6 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
+import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators';
import { Injectable, InjectionToken } from '@angular/core';
-import { 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 = new InjectionToken('filterConfig');
+export const IN_PLACE_SEARCH: InjectionToken = new InjectionToken('inPlaceSearch');
/**
* Service that performs all actions that have to do with search filters and facets
@@ -29,8 +36,8 @@ export const FILTER_CONFIG: InjectionToken = new InjectionTo
export class SearchFilterService {
constructor(private store: Store,
- private routeService: RouteService
- ) {
+ private routeService: RouteService,
+ private fixedFilterService: SearchFixedFilterService) {
}
/**
@@ -52,6 +59,81 @@ export class SearchFilterService {
return this.routeService.hasQueryParam(paramName);
}
+ /**
+ * Fetch the current active scope from the query parameters
+ * @returns {Observable}
+ */
+ getCurrentScope() {
+ return this.routeService.getQueryParameterValue('scope');
+ }
+
+ /**
+ * Fetch the current query from the query parameters
+ * @returns {Observable}
+ */
+ getCurrentQuery() {
+ return this.routeService.getQueryParameterValue('query');
+ }
+
+ /**
+ * Fetch the current pagination from query parameters 'page' and 'pageSize'
+ * and combine them with a given pagination
+ * @param pagination Pagination options to combine the query parameters with
+ * @returns {Observable}
+ */
+ getCurrentPagination(pagination: any = {}): Observable {
+ const page$ = this.routeService.getQueryParameterValue('page');
+ const size$ = this.routeService.getQueryParameterValue('pageSize');
+ return observableCombineLatest(page$, size$).pipe(map(([page, size]) => {
+ return Object.assign(new PaginationComponentOptions(), pagination, {
+ currentPage: page || 1,
+ pageSize: size || pagination.pageSize
+ });
+ }))
+ }
+
+ /**
+ * Fetch the current sorting options from query parameters 'sortDirection' and 'sortField'
+ * and combine them with given sorting options
+ * @param {SortOptions} defaultSort Sorting options to combine the query parameters with
+ * @returns {Observable}
+ */
+ getCurrentSort(defaultSort: SortOptions): Observable {
+ const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
+ const sortField$ = this.routeService.getQueryParameterValue('sortField');
+ return observableCombineLatest(sortDirection$, sortField$).pipe(map(([sortDirection, sortField]) => {
+ const field = sortField || defaultSort.field;
+ const direction = SortDirection[sortDirection] || defaultSort.direction;
+ return new SortOptions(field, direction)
+ }
+ ))
+ }
+
+ /**
+ * Fetch the current active filters from the query parameters
+ * @returns {Observable}
+ */
+ getCurrentFilters() {
+ return this.routeService.getQueryParamsWithPrefix('f.');
+ }
+
+ /**
+ * Fetch the current active fixed filter from the route parameters and return the query by filter name
+ * @returns {Observable}
+ */
+ getCurrentFixedFilter(): Observable {
+ const filter: Observable = this.routeService.getRouteParameterValue('filter');
+ return filter.pipe(mergeMap((f) => this.fixedFilterService.getQueryByFilterName(f)));
+ }
+
+ /**
+ * Fetch the current view from the query parameters
+ * @returns {Observable}
+ */
+ getCurrentView() {
+ return this.routeService.getQueryParameterValue('view');
+ }
+
/**
* Requests the active filter values set for a given filter
* @param {SearchFilterConfig} filterConfig The configuration for which the filters are active
@@ -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 0000000000..3207345564
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts
@@ -0,0 +1,60 @@
+import { SearchFixedFilterService } from './search-fixed-filter.service';
+import { RouteService } from '../../../shared/services/route.service';
+import { RequestService } from '../../../core/data/request.service';
+import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
+import { of as observableOf } from 'rxjs';
+import { RequestEntry } from '../../../core/data/request.reducer';
+import { FilteredDiscoveryQueryResponse, RestResponse } from '../../../core/cache/response.models';
+
+describe('SearchFixedFilterService', () => {
+ let service: SearchFixedFilterService;
+
+ const filterQuery = 'filter:query';
+
+ const routeServiceStub = {} as RouteService;
+ const requestServiceStub = Object.assign({
+ /* tslint:disable:no-empty */
+ configure: () => {},
+ /* tslint:enable:no-empty */
+ generateRequestId: () => 'fake-id',
+ getByUUID: () => observableOf(Object.assign(new RequestEntry(), {
+ response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK')
+ }))
+ }) as RequestService;
+ const halServiceStub = Object.assign(new HALEndpointService(requestServiceStub, undefined), {
+ getEndpoint: () => observableOf('fake-url')
+ });
+
+ beforeEach(() => {
+ service = new SearchFixedFilterService(routeServiceStub, requestServiceStub, halServiceStub);
+ });
+
+ describe('when getQueryByFilterName is called with a filterName', () => {
+ it('should return the filter query', () => {
+ service.getQueryByFilterName('filter').subscribe((query) => {
+ expect(query).toBe(filterQuery);
+ });
+ });
+ });
+
+ describe('when getQueryByFilterName is called without a filterName', () => {
+ it('should return undefined', () => {
+ service.getQueryByFilterName(undefined).subscribe((query) => {
+ expect(query).toBeUndefined();
+ });
+ });
+ });
+
+ describe('when getQueryByRelations is called', () => {
+ const relationType = 'isRelationOf';
+ const itemUUID = 'c5b277e6-2477-48bb-8993-356710c285f3';
+
+ it('should contain the relationType and itemUUID', () => {
+ const query = service.getQueryByRelations(relationType, itemUUID);
+ expect(query.length).toBeGreaterThan(relationType.length + itemUUID.length);
+ expect(query).toContain(relationType);
+ expect(query).toContain(itemUUID);
+ });
+ });
+
+});
diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts
new file mode 100644
index 0000000000..7d59e5a446
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts
@@ -0,0 +1,79 @@
+import { Injectable } from '@angular/core';
+import { flatMap, map } from 'rxjs/operators';
+import { Observable , of as observableOf } from 'rxjs';
+import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
+import { GetRequest, RestRequest } from '../../../core/data/request.models';
+import { RequestService } from '../../../core/data/request.service';
+import { ResponseParsingService } from '../../../core/data/parsing.service';
+import { GenericConstructor } from '../../../core/shared/generic-constructor';
+import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service';
+import { hasValue } from '../../../shared/empty.util';
+import { configureRequest, getResponseFromEntry } from '../../../core/shared/operators';
+import { RouteService } from '../../../shared/services/route.service';
+import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models';
+
+/**
+ * Service for performing actions on the filtered-discovery-pages REST endpoint
+ */
+@Injectable()
+export class SearchFixedFilterService {
+ private queryByFilterPath = 'filtered-discovery-pages';
+
+ constructor(private routeService: RouteService,
+ protected requestService: RequestService,
+ private halService: HALEndpointService) {
+
+ }
+
+ /**
+ * Get the filter query for a certain filter by name
+ * @param {string} filterName Name of the filter
+ * @returns {Observable} Filter query
+ */
+ getQueryByFilterName(filterName: string): Observable {
+ if (hasValue(filterName)) {
+ const requestUuid = this.requestService.generateRequestId();
+ this.halService.getEndpoint(this.queryByFilterPath).pipe(
+ map((url: string) => {
+ url += ('/' + filterName);
+ const request = new GetRequest(requestUuid, url);
+ return Object.assign(request, {
+ getResponseParser(): GenericConstructor {
+ return FilteredDiscoveryPageResponseParsingService;
+ }
+ });
+ }),
+ configureRequest(this.requestService)
+ ).subscribe();
+
+ // get search results from response cache
+ const filterQuery: Observable = this.requestService.getByUUID(requestUuid).pipe(
+ getResponseFromEntry(),
+ map((response: FilteredDiscoveryQueryResponse) =>
+ response.filterQuery
+ ));
+ return filterQuery;
+ }
+ return observableOf(undefined);
+ }
+
+ /**
+ * Get the query for looking up items by relation type
+ * @param {string} relationType Relation type
+ * @param {string} itemUUID Item UUID
+ * @returns {string} Query
+ */
+ getQueryByRelations(relationType: string, itemUUID: string): string {
+ return `query=relation.${relationType}:${itemUUID}`;
+ }
+
+ /**
+ * Get the filter for a relation with the item's UUID
+ * @param relationType The type of relation e.g. 'isAuthorOfPublication'
+ * @param itemUUID The item's UUID
+ */
+ getFilterByRelation(relationType: string, itemUUID: string): string {
+ return `f.${relationType}=${itemUUID}`;
+ }
+
+}
diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
index b6ae0ada63..ac2a72f4b6 100644
--- a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
@@ -1,9 +1,9 @@
-
+
-
+
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 9d35cc518a..cad31e7f0f 100644
--- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html
@@ -24,7 +24,7 @@
-
+
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 930ea8c9fb..119f3f92a9 100644
--- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts
@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
+import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { FilterType } from '../../../search-service/filter-type.model';
import { FacetValue } from '../../../search-service/facet-value.model';
@@ -18,7 +18,8 @@ import { PageInfo } from '../../../../core/shared/page-info.model';
import { SearchRangeFilterComponent } from './search-range-filter.component';
import { RouteService } from '../../../../shared/services/route.service';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
-import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
+import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub';
describe('SearchRangeFilterComponent', () => {
let comp: SearchRangeFilterComponent;
@@ -41,14 +42,17 @@ describe('SearchRangeFilterComponent', () => {
});
const values: FacetValue[] = [
{
+ label: value1,
value: value1,
count: 52,
search: ''
}, {
+ label: value2,
value: value2,
count: 20,
search: ''
}, {
+ label: value3,
value: value3,
count: 5,
search: ''
@@ -73,9 +77,8 @@ describe('SearchRangeFilterComponent', () => {
{ provide: FILTER_CONFIG, useValue: mockFilterConfig },
{ provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} },
{ provide: RouteService, useValue: {getQueryParameterValue: () => observableOf({})} },
- { provide: SearchConfigurationService, useValue: {
- searchOptions: observableOf({}) }
- },
+ { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
+ { provide: IN_PLACE_SEARCH, useValue: false },
{
provide: SearchFilterService, useValue: {
getSelectedValuesForFilter: () => selectedValues,
@@ -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 ebdb797500..95d7441184 100644
--- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts
@@ -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 25ff8e46d3..a4f4fb5ee8 100644
--- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html
+++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html
@@ -1,9 +1,9 @@
-
+
-
+
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 895765f6ac..05f4a693c2 100644
--- a/src/app/+search-page/search-filters/search-filters.component.html
+++ b/src/app/+search-page/search-filters/search-filters.component.html
@@ -1,7 +1,7 @@
{{"search.filters.head" | translate}}
-
{{"search.filters.reset" | translate}}
\ No newline at end of file
+
{{"search.filters.reset" | translate}}
diff --git a/src/app/+search-page/search-filters/search-filters.component.spec.ts b/src/app/+search-page/search-filters/search-filters.component.spec.ts
index db21fc8a69..dc883cd290 100644
--- a/src/app/+search-page/search-filters/search-filters.component.spec.ts
+++ b/src/app/+search-page/search-filters/search-filters.component.spec.ts
@@ -7,13 +7,15 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFilterService } from './search-filter/search-filter.service';
import { SearchFiltersComponent } from './search-filters.component';
import { SearchService } from '../search-service/search.service';
-import { SearchConfigurationService } from '../search-service/search-configuration.service';
import { of as observableOf } from 'rxjs';
+import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub';
describe('SearchFiltersComponent', () => {
let comp: SearchFiltersComponent;
let fixture: ComponentFixture
;
let searchService: SearchService;
+
const searchServiceStub = {
/* tslint:disable:no-empty */
getConfig: () =>
@@ -30,17 +32,13 @@ describe('SearchFiltersComponent', () => {
[]
};
- const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', {
- getCurrentFrontendFilters: observableOf({})
- });
-
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
declarations: [SearchFiltersComponent],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
- { provide: SearchConfigurationService, useValue: searchConfigServiceStub },
+ { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
{ provide: SearchFilterService, useValue: searchFiltersStub },
],
diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts
index 1dd747e908..e970647747 100644
--- a/src/app/+search-page/search-filters/search-filters.component.ts
+++ b/src/app/+search-page/search-filters/search-filters.component.ts
@@ -1,13 +1,15 @@
-import { Observable } from 'rxjs';
+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 61a5618dad..cac81e8717 100644
--- a/src/app/+search-page/search-labels/search-labels.component.html
+++ b/src/app/+search-page/search-labels/search-labels.component.html
@@ -2,11 +2,11 @@
diff --git a/src/app/+search-page/search-labels/search-labels.component.spec.ts b/src/app/+search-page/search-labels/search-labels.component.spec.ts
index 81fa5b5df8..d28698764c 100644
--- a/src/app/+search-page/search-labels/search-labels.component.spec.ts
+++ b/src/app/+search-page/search-labels/search-labels.component.spec.ts
@@ -9,7 +9,8 @@ import { SearchServiceStub } from '../../shared/testing/search-service-stub';
import { Observable, of as observableOf } from 'rxjs';
import { Params } from '@angular/router';
import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe';
-import { SearchConfigurationService } from '../search-service/search-configuration.service';
+import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub';
describe('SearchLabelsComponent', () => {
let comp: SearchLabelsComponent;
@@ -20,8 +21,11 @@ describe('SearchLabelsComponent', () => {
const field1 = 'author';
const field2 = 'subject';
- const value1 = 'TestAuthor';
+ const value1 = 'Test, Author';
+ const normValue1 = 'Test, Author';
const value2 = 'TestSubject';
+ const value3 = 'Test, Authority,authority';
+ const normValue3 = 'Test, Authority';
const filter1 = [field1, value1];
const filter2 = [field2, value2];
const mockFilters = [
@@ -35,7 +39,8 @@ describe('SearchLabelsComponent', () => {
declarations: [SearchLabelsComponent, ObjectKeysPipe],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
- { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} }
+ { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }
+ // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchLabelsComponent, {
@@ -65,4 +70,16 @@ describe('SearchLabelsComponent', () => {
});
})
});
+
+ describe('when normalizeFilterValue is called', () => {
+ it('should return properly filter value', () => {
+ let result: string;
+
+ result = comp.normalizeFilterValue(value1);
+ expect(result).toBe(normValue1);
+
+ result = comp.normalizeFilterValue(value3);
+ expect(result).toBe(normValue3);
+ })
+ });
});
diff --git a/src/app/+search-page/search-labels/search-labels.component.ts b/src/app/+search-page/search-labels/search-labels.component.ts
index 08e07cce3d..104ed5b08b 100644
--- a/src/app/+search-page/search-labels/search-labels.component.ts
+++ b/src/app/+search-page/search-labels/search-labels.component.ts
@@ -1,10 +1,11 @@
-import { Component } from '@angular/core';
+import { Component, Inject, Input } from '@angular/core';
import { SearchService } from '../search-service/search.service';
import { Observable } from 'rxjs';
import { Params } from '@angular/router';
import { map } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
+import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
@Component({
selector: 'ds-search-labels',
@@ -21,10 +22,17 @@ export class SearchLabelsComponent {
*/
appliedFilters: Observable;
+ /**
+ * True when the search component should show results on the current page
+ */
+ @Input() inPlaceSearch;
+
/**
* Initialize the instance variable
*/
- constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService) {
+ constructor(
+ private searchService: SearchService,
+ @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters();
}
@@ -48,9 +56,25 @@ export class SearchLabelsComponent {
}
/**
- * @returns {string} The base path to the search page
+ * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
*/
- getSearchLink() {
+ public getSearchLink(): string {
+ if (this.inPlaceSearch) {
+ return './';
+ }
return this.searchService.getSearchLink();
}
+
+ /**
+ * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved
+ * Strips authority operator from filter value
+ * e.g. 'test ,authority' => 'test'
+ *
+ * @param value
+ */
+ normalizeFilterValue(value: string) {
+ // const pattern = /,[^,]*$/g;
+ const pattern = /,authority*$/g;
+ return value.replace(pattern, '');
+ }
}
diff --git a/src/app/+search-page/search-options.model.ts b/src/app/+search-page/search-options.model.ts
index 123cf950f8..2b18854e1e 100644
--- a/src/app/+search-page/search-options.model.ts
+++ b/src/app/+search-page/search-options.model.ts
@@ -3,21 +3,27 @@ import { URLCombiner } from '../core/url-combiner/url-combiner';
import 'core-js/library/fn/object/entries';
import { SearchFilter } from './search-filter.model';
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
+import { SetViewMode } from '../shared/view-mode';
/**
* This model class represents all parameters needed to request information about a certain search request
*/
export class SearchOptions {
+ configuration?: string;
+ view?: SetViewMode = SetViewMode.List;
scope?: string;
query?: string;
dsoType?: DSpaceObjectType;
- filters?: SearchFilter[];
+ filters?: any;
+ fixedFilter?: any;
- constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[]}) {
+ constructor(options: {configuration?: string, scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any}) {
+ this.configuration = options.configuration;
this.scope = options.scope;
this.query = options.query;
this.dsoType = options.dsoType;
this.filters = options.filters;
+ this.fixedFilter = options.fixedFilter;
}
/**
@@ -27,7 +33,12 @@ export class SearchOptions {
* @returns {string} URL with all search options and passed arguments as query parameters
*/
toRestUrl(url: string, args: string[] = []): string {
-
+ if (isNotEmpty(this.configuration)) {
+ args.push(`configuration=${this.configuration}`);
+ }
+ if (isNotEmpty(this.fixedFilter)) {
+ args.push(this.fixedFilter);
+ }
if (isNotEmpty(this.query)) {
args.push(`query=${this.query}`);
}
@@ -39,7 +50,10 @@ export class SearchOptions {
}
if (isNotEmpty(this.filters)) {
this.filters.forEach((filter: SearchFilter) => {
- filter.values.forEach((value) => args.push(`${filter.key}=${value},${filter.operator}`));
+ filter.values.forEach((value) => {
+ const filterValue = value.includes(',') ? `${value}` : `${value},${filter.operator}`;
+ args.push(`${filter.key}=${filterValue}`)
+ });
});
}
if (isNotEmpty(args)) {
diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts
index 65cca99a34..8c138c0d52 100644
--- a/src/app/+search-page/search-page-routing.module.ts
+++ b/src/app/+search-page/search-page-routing.module.ts
@@ -2,11 +2,14 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SearchPageComponent } from './search-page.component';
+import { FilteredSearchPageComponent } from './filtered-search-page.component';
+import { FilteredSearchPageGuard } from './filtered-search-page.guard';
@NgModule({
imports: [
RouterModule.forChild([
- { path: '', component: SearchPageComponent, data: { title: 'search.title' } }
+ { path: '', component: SearchPageComponent, data: { title: 'search.title' } },
+ { path: ':filter', component: FilteredSearchPageComponent, canActivate: [FilteredSearchPageGuard], data: { title: 'search.' }}
])
]
})
diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html
index 6476f8bd68..c11e863429 100644
--- a/src/app/+search-page/search-page.component.html
+++ b/src/app/+search-page/search-page.component.html
@@ -1,16 +1,17 @@
-
-
-
+
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.scss b/src/app/+search-page/search-sidebar/search-sidebar.component.scss
index b5bd6dd30d..35ce5eebce 100644
--- a/src/app/+search-page/search-sidebar/search-sidebar.component.scss
+++ b/src/app/+search-page/search-sidebar/search-sidebar.component.scss
@@ -8,8 +8,12 @@
ds-view-mode-switch {
margin-bottom: $spacer;
}
- .sidebar-content > *:not(:last-child) {
+ .sidebar-content > *:not(:last-child):not(ds-search-switch-configuration) {
margin-bottom: 4*$spacer;
display: block;
}
+ ds-search-switch-configuration {
+ margin-bottom: 2*$spacer;
+ display: block;
+ }
}
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.ts b/src/app/+search-page/search-sidebar/search-sidebar.component.ts
index 8b68cda793..9ee0a74942 100644
--- a/src/app/+search-page/search-sidebar/search-sidebar.component.ts
+++ b/src/app/+search-page/search-sidebar/search-sidebar.component.ts
@@ -1,5 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model';
+
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
@@ -17,13 +19,29 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
*/
export class SearchSidebarComponent {
+ /**
+ * The list of available configuration options
+ */
+ @Input() configurationList: SearchConfigurationOption[];
+
/**
* The total amount of results
*/
@Input() resultCount;
+ /**
+ * The list of available view mode options
+ */
+ @Input() viewModeList;
+
+ /**
+ * True when the search component should show results on the current page
+ */
+ @Input() inPlaceSearch;
+
/**
* Emits event when the user clicks a button to open or close the sidebar
*/
@Output() toggleSidebar = new EventEmitter();
+
}
diff --git a/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts
new file mode 100644
index 0000000000..6f9a72da48
--- /dev/null
+++ b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts
@@ -0,0 +1,15 @@
+/**
+ * Represents a search configuration select option
+ */
+export interface SearchConfigurationOption {
+
+ /**
+ * The select option value
+ */
+ value: string;
+
+ /**
+ * The select option label
+ */
+ label: string;
+}
diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html
new file mode 100644
index 0000000000..8df37214d1
--- /dev/null
+++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html
@@ -0,0 +1,13 @@
+ 1" class="search-switch-configuration">
+
{{ 'search.switch-configuration.title' | translate}}
+
+
+
+
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 0000000000..e69de29bb2
diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts
new file mode 100644
index 0000000000..b3efc240e1
--- /dev/null
+++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts
@@ -0,0 +1,109 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { of as observableOf } from 'rxjs';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+
+import { SearchSwitchConfigurationComponent } from './search-switch-configuration.component';
+import { MYDSPACE_ROUTE, SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub';
+import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
+import { NavigationExtras, Router } from '@angular/router';
+import { RouterStub } from '../../shared/testing/router-stub';
+import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type';
+import { SearchService } from '../search-service/search.service';
+
+describe('SearchSwitchConfigurationComponent', () => {
+
+ let comp: SearchSwitchConfigurationComponent;
+ let fixture: ComponentFixture;
+ let searchConfService: SearchConfigurationServiceStub;
+ let select: any;
+
+ const searchServiceStub = jasmine.createSpyObj('SearchService', {
+ getSearchLink: jasmine.createSpy('getSearchLink')
+ });
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ })
+ ],
+ declarations: [ SearchSwitchConfigurationComponent ],
+ providers: [
+ { provide: Router, useValue: new RouterStub() },
+ { provide: SearchService, useValue: searchServiceStub },
+ { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
+ ],
+ schemas: [ NO_ERRORS_SCHEMA ]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SearchSwitchConfigurationComponent);
+ comp = fixture.componentInstance;
+ searchConfService = TestBed.get(SEARCH_CONFIG_SERVICE);
+
+ spyOn(searchConfService, 'getCurrentConfiguration').and.returnValue(observableOf(MyDSpaceConfigurationValueType.Workspace));
+
+ comp.configurationList = [
+ {
+ value: MyDSpaceConfigurationValueType.Workspace,
+ label: 'workspace'
+ },
+ {
+ value: MyDSpaceConfigurationValueType.Workflow,
+ label: 'workflow'
+ },
+ ];
+
+ // SearchSwitchConfigurationComponent test instance
+ fixture.detectChanges();
+
+ });
+
+ it('should init the current configuration name', () => {
+ expect(comp.selectedOption).toBe(MyDSpaceConfigurationValueType.Workspace);
+ });
+
+ it('should display select field properly', () => {
+ const selectField = fixture.debugElement.query(By.css('.form-control'));
+ expect(selectField).toBeDefined();
+
+ const childElements = selectField.children;
+ expect(childElements.length).toEqual(comp.configurationList.length);
+ });
+
+ it('should call onSelect method when selecting an option', () => {
+ fixture.whenStable().then(() => {
+ spyOn(comp, 'onSelect');
+ select = fixture.debugElement.query(By.css('select'));
+ const selectEl = select.nativeElement;
+ selectEl.value = selectEl.options[1].value; // <-- select a new value
+ selectEl.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+ expect(comp.onSelect).toHaveBeenCalled();
+ });
+
+ });
+
+ it('should navigate to the route when selecting an option', () => {
+ (comp as any).searchService.getSearchLink.and.returnValue(MYDSPACE_ROUTE);
+ comp.selectedOption = MyDSpaceConfigurationValueType.Workflow;
+ const navigationExtras: NavigationExtras = {
+ queryParams: {configuration: MyDSpaceConfigurationValueType.Workflow},
+ };
+
+ fixture.detectChanges();
+
+ comp.onSelect();
+
+ expect((comp as any).router.navigate).toHaveBeenCalledWith([MYDSPACE_ROUTE], navigationExtras);
+ });
+});
diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts
new file mode 100644
index 0000000000..c34fe20303
--- /dev/null
+++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts
@@ -0,0 +1,80 @@
+import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core';
+import { NavigationExtras, Router } from '@angular/router';
+
+import { Subscription } from 'rxjs';
+
+import { hasValue } from '../../shared/empty.util';
+import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationService } from '../search-service/search-configuration.service';
+import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type';
+import { SearchConfigurationOption } from './search-configuration-option.model';
+import { SearchService } from '../search-service/search.service';
+
+@Component({
+ selector: 'ds-search-switch-configuration',
+ styleUrls: ['./search-switch-configuration.component.scss'],
+ templateUrl: './search-switch-configuration.component.html',
+})
+/**
+ * Represents a select that allow to switch over available search configurations
+ */
+export class SearchSwitchConfigurationComponent implements OnDestroy, OnInit {
+
+ /**
+ * The list of available configuration options
+ */
+ @Input() configurationList: SearchConfigurationOption[] = [];
+
+ /**
+ * The selected option
+ */
+ public selectedOption: string;
+
+ /**
+ * Subscription to unsubscribe from
+ */
+ private sub: Subscription;
+
+ constructor(private router: Router,
+ private searchService: SearchService,
+ @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) {
+ }
+
+ /**
+ * Init current configuration
+ */
+ ngOnInit() {
+ this.searchConfigService.getCurrentConfiguration('default')
+ .subscribe((currentConfiguration) => this.selectedOption = currentConfiguration);
+ }
+
+ /**
+ * Init current configuration
+ */
+ onSelect() {
+ const navigationExtras: NavigationExtras = {
+ queryParams: {configuration: this.selectedOption},
+ };
+
+ this.router.navigate([this.searchService.getSearchLink()], navigationExtras);
+ }
+
+ /**
+ * Define the select 'compareWith' method to tell Angular how to compare the values
+ *
+ * @param item1
+ * @param item2
+ */
+ compare(item1: MyDSpaceConfigurationValueType, item2: MyDSpaceConfigurationValueType) {
+ return item1 === item2;
+ }
+
+ /**
+ * Make sure the subscription is unsubscribed from when this component is destroyed
+ */
+ ngOnDestroy() {
+ if (hasValue(this.sub)) {
+ this.sub.unsubscribe();
+ }
+ }
+}
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index be956ee895..cb80d0165e 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-object-factory.ts b/src/app/core/auth/auth-object-factory.ts
index e37475d94c..02458f4e3e 100644
--- a/src/app/core/auth/auth-object-factory.ts
+++ b/src/app/core/auth/auth-object-factory.ts
@@ -4,6 +4,7 @@ import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { CacheableObject } from '../cache/object-cache.reducer';
+import { NormalizedGroup } from '../eperson/models/normalized-group.model';
export class AuthObjectFactory {
public static getConstructor(type): GenericConstructor> {
@@ -12,6 +13,10 @@ export class AuthObjectFactory {
return NormalizedEPerson
}
+ case AuthType.Group: {
+ return NormalizedGroup
+ }
+
case AuthType.Status: {
return NormalizedAuthStatus
}
diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts
index 6d782cbbe2..cbabe5c3fd 100644
--- a/src/app/core/auth/auth-request.service.ts
+++ b/src/app/core/auth/auth-request.service.ts
@@ -6,10 +6,9 @@ import { RequestService } from '../data/request.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { isNotEmpty } from '../../shared/empty.util';
-import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models';
+import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models';
import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
-import { RequestEntry } from '../data/request.reducer';
import { getResponseFromEntry } from '../shared/operators';
@Injectable()
@@ -56,8 +55,8 @@ export class AuthRequestService {
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),
distinctUntilChanged(),
map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options)),
- tap((request: PostRequest) => this.requestService.configure(request, true)),
- mergeMap((request: PostRequest) => this.fetchRequest(request)),
+ tap((request: GetRequest) => this.requestService.configure(request, true)),
+ mergeMap((request: GetRequest) => this.fetchRequest(request)),
distinctUntilChanged());
}
}
diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts
index 3cb00789f6..c736c3b22b 100644
--- a/src/app/core/auth/auth-response-parsing.service.ts
+++ b/src/app/core/auth/auth-response-parsing.service.ts
@@ -13,6 +13,7 @@ import { RestRequest } from '../data/request.models';
import { AuthType } from './auth-type';
import { AuthStatus } from './models/auth-status.model';
import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
+import { NormalizedObject } from '../cache/models/normalized-object.model';
@Injectable()
export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
@@ -27,11 +28,10 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 200)) {
- const response = this.process(data.payload, request.uuid);
+ const response = this.process, AuthType>(data.payload, request.uuid);
return new AuthStatusResponse(response, data.statusCode, data.statusText);
} else {
- return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode, data.statusText);
+ return new AuthStatusResponse(data.payload as NormalizedAuthStatus, data.statusCode, data.statusText);
}
}
-
}
diff --git a/src/app/core/auth/auth-type.ts b/src/app/core/auth/auth-type.ts
index 9a248da91f..f0460449ea 100644
--- a/src/app/core/auth/auth-type.ts
+++ b/src/app/core/auth/auth-type.ts
@@ -1,4 +1,5 @@
export enum AuthType {
EPerson = 'eperson',
- Status = 'status'
+ Status = 'status',
+ Group = 'group'
}
diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts
index c461148eea..e766a45e48 100644
--- a/src/app/core/auth/auth.service.spec.ts
+++ b/src/app/core/auth/auth.service.spec.ts
@@ -26,21 +26,24 @@ import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-da
describe('AuthService test', () => {
- const mockStore: Store = jasmine.createSpyObj('store', {
- dispatch: {},
- pipe: observableOf(true)
- });
+ let mockStore: Store;
let authService: AuthService;
let authRequest;
- const window = new NativeWindowRef();
- const routerStub = new RouterStub();
+ let window;
+ let routerStub;
let routeStub;
let storage: CookieService;
let token: AuthTokenInfo;
let authenticatedState;
- const rdbService = getMockRemoteDataBuildService();
+ let rdbService;
function init() {
+ mockStore = jasmine.createSpyObj('store', {
+ dispatch: {},
+ pipe: observableOf(true)
+ });
+ window = new NativeWindowRef();
+ routerStub = new RouterStub();
token = new AuthTokenInfo('test_token');
token.expires = Date.now() + (1000 * 60 * 60);
authenticatedState = {
@@ -52,15 +55,14 @@ describe('AuthService test', () => {
};
authRequest = new AuthRequestServiceStub();
routeStub = new ActivatedRouteStub();
- }
+ rdbService = getMockRemoteDataBuildService();
+ spyOn(rdbService, 'build').and.returnValue({authenticated: true, eperson: observableOf({payload: {}})});
- beforeEach(() => {
- init();
- });
+ }
describe('', () => {
beforeEach(() => {
-
+ init();
TestBed.configureTestingModule({
imports: [
CommonModule,
@@ -137,7 +139,8 @@ describe('AuthService test', () => {
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: RemoteDataBuildService, useValue: rdbService },
- CookieService
+ CookieService,
+ AuthService
]
}).compileComponents();
}));
@@ -176,8 +179,8 @@ describe('AuthService test', () => {
});
describe('', () => {
-
beforeEach(async(() => {
+ init();
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({ authReducer })
@@ -186,8 +189,10 @@ describe('AuthService test', () => {
{ provide: AuthRequestService, useValue: authRequest },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
+ { provide: RemoteDataBuildService, useValue: rdbService },
ClientCookieService,
- CookieService
+ CookieService,
+ AuthService
]
}).compileComponents();
}));
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index fdb372f643..a01768e687 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';
@@ -146,14 +130,10 @@ export class AuthService {
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers;
return this.authRequestService.getRequest('status', options).pipe(
+ map((status) => this.rdbService.build(status)),
switchMap((status: AuthStatus) => {
-
if (status.authenticated) {
- // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
- // Review when https://jira.duraspace.org/browse/DS-4006 is fixed
- // See https://github.com/DSpace/dspace-angular/issues/292
- const person$ = this.rdbService.buildSingle(status.eperson.toString());
- return person$.pipe(map((eperson) => eperson.payload));
+ return status.eperson.pipe(map((eperson) => eperson.payload));
} else {
throw(new Error('Not authenticated'));
}
@@ -242,7 +222,6 @@ export class AuthService {
throw(new Error('auth.errors.invalid-user'));
}
}))
-
}
/**
diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts
index 37f8d76672..6e722a80c9 100644
--- a/src/app/core/auth/models/auth-status.model.ts
+++ b/src/app/core/auth/models/auth-status.model.ts
@@ -3,8 +3,9 @@ import { AuthTokenInfo } from './auth-token-info.model';
import { EPerson } from '../../eperson/models/eperson.model';
import { RemoteData } from '../../data/remote-data';
import { Observable } from 'rxjs';
+import { CacheableObject } from '../../cache/object-cache.reducer';
-export class AuthStatus {
+export class AuthStatus implements CacheableObject {
id: string;
diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts
index fa637981ae..8c88e0fce5 100644
--- a/src/app/core/auth/selectors.ts
+++ b/src/app/core/auth/selectors.ts
@@ -8,6 +8,7 @@ import { createSelector } from '@ngrx/store';
*/
import { AuthState } from './auth.reducer';
import { AppState } from '../../app.reducer';
+import { EPerson } from '../eperson/models/eperson.model';
/**
* Returns the user state.
@@ -35,11 +36,12 @@ const _isAuthenticatedLoaded = (state: AuthState) => state.loaded;
/**
* Return the users state
+ * NOTE: when state is REHYDRATED user object lose prototype so return always a new EPerson object
* @function _getAuthenticatedUser
* @param {State} state
- * @returns {User}
+ * @returns {EPerson}
*/
-const _getAuthenticatedUser = (state: AuthState) => state.user;
+const _getAuthenticatedUser = (state: AuthState) => Object.assign(new EPerson(), state.user);
/**
* Returns the authentication error.
diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts
index b61b11a4f2..c344683e38 100644
--- a/src/app/core/auth/server-auth.service.ts
+++ b/src/app/core/auth/server-auth.service.ts
@@ -34,15 +34,10 @@ export class ServerAuthService extends AuthService {
options.headers = headers;
return this.authRequestService.getRequest('status', options).pipe(
+ map((status) => this.rdbService.build(status)),
switchMap((status: AuthStatus) => {
-
if (status.authenticated) {
-
- // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
- const person$ = this.rdbService.buildSingle(status.eperson.toString());
- return person$.pipe(
- map((eperson) => eperson.payload)
- );
+ return status.eperson.pipe(map((eperson) => eperson.payload));
} else {
throw(new Error('Not authenticated'));
}
diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts
index c0b359e7ea..563dce23d1 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) => d.isRequestPending)
- .every((b: boolean) => b === true);
+ .find((b: boolean) => b === true);
- const responsePending: boolean = arr
+ // The response of an aggregate RD should be pending if no requests
+ // are still pending and at least one of the RDs it's based
+ // on is still in the state ResponsePending
+ const responsePending: boolean = !requestPending && arr
.map((d: RemoteData) => d.isResponsePending)
- .every((b: boolean) => b === true);
+ .find((b: boolean) => b === true);
- const isSuccessful: boolean = arr
- .map((d: RemoteData) => d.hasSucceeded)
- .every((b: boolean) => b === true);
+ let isSuccessful: boolean;
+ // isSuccessful should be undefined until all responses have come in.
+ // We can't know its state beforehand. We also can't say it's false
+ // because that would imply a request failed.
+ if (!(requestPending || responsePending)) {
+ isSuccessful = arr
+ .map((d: RemoteData) => d.hasSucceeded)
+ .every((b: boolean) => b === true);
+ }
const errorMessage: string = arr
.map((d: RemoteData) => d.error)
diff --git a/src/app/core/cache/models/items/normalized-item-type.model.ts b/src/app/core/cache/models/items/normalized-item-type.model.ts
new file mode 100644
index 0000000000..ed38d80a4b
--- /dev/null
+++ b/src/app/core/cache/models/items/normalized-item-type.model.ts
@@ -0,0 +1,32 @@
+import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
+import { ItemType } from '../../../shared/item-relationships/item-type.model';
+import { ResourceType } from '../../../shared/resource-type';
+import { mapsTo } from '../../builders/build-decorators';
+import { NormalizedObject } from '../normalized-object.model';
+import { IDToUUIDSerializer } from '../../id-to-uuid-serializer';
+
+/**
+ * Normalized model class for a DSpace ItemType
+ */
+@mapsTo(ItemType)
+@inheritSerialization(NormalizedObject)
+export class NormalizedItemType extends NormalizedObject {
+
+ /**
+ * The label that describes the ResourceType of the Item
+ */
+ @autoserialize
+ label: string;
+
+ /**
+ * The identifier of this ItemType
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The universally unique identifier of this ItemType
+ */
+ @autoserializeAs(new IDToUUIDSerializer(ResourceType.ItemType), 'id')
+ uuid: string;
+}
diff --git a/src/app/core/cache/models/items/normalized-relationship-type.model.ts b/src/app/core/cache/models/items/normalized-relationship-type.model.ts
new file mode 100644
index 0000000000..d201fb2746
--- /dev/null
+++ b/src/app/core/cache/models/items/normalized-relationship-type.model.ts
@@ -0,0 +1,77 @@
+import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
+import { RelationshipType } from '../../../shared/item-relationships/relationship-type.model';
+import { ResourceType } from '../../../shared/resource-type';
+import { mapsTo, relationship } from '../../builders/build-decorators';
+import { NormalizedDSpaceObject } from '../normalized-dspace-object.model';
+import { NormalizedObject } from '../normalized-object.model';
+import { IDToUUIDSerializer } from '../../id-to-uuid-serializer';
+
+/**
+ * Normalized model class for a DSpace RelationshipType
+ */
+@mapsTo(RelationshipType)
+@inheritSerialization(NormalizedDSpaceObject)
+export class NormalizedRelationshipType extends NormalizedObject {
+
+ /**
+ * The identifier of this RelationshipType
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The label that describes the Relation to the left of this RelationshipType
+ */
+ @autoserialize
+ leftLabel: string;
+
+ /**
+ * The maximum amount of Relationships allowed to the left of this RelationshipType
+ */
+ @autoserialize
+ leftMaxCardinality: number;
+
+ /**
+ * The minimum amount of Relationships allowed to the left of this RelationshipType
+ */
+ @autoserialize
+ leftMinCardinality: number;
+
+ /**
+ * The label that describes the Relation to the right of this RelationshipType
+ */
+ @autoserialize
+ rightLabel: string;
+
+ /**
+ * The maximum amount of Relationships allowed to the right of this RelationshipType
+ */
+ @autoserialize
+ rightMaxCardinality: number;
+
+ /**
+ * The minimum amount of Relationships allowed to the right of this RelationshipType
+ */
+ @autoserialize
+ rightMinCardinality: number;
+
+ /**
+ * The type of Item found to the left of this RelationshipType
+ */
+ @autoserialize
+ @relationship(ResourceType.ItemType, false)
+ leftType: string;
+
+ /**
+ * The type of Item found to the right of this RelationshipType
+ */
+ @autoserialize
+ @relationship(ResourceType.ItemType, false)
+ rightType: string;
+
+ /**
+ * The universally unique identifier of this RelationshipType
+ */
+ @autoserializeAs(new IDToUUIDSerializer(ResourceType.RelationshipType), 'id')
+ uuid: string;
+}
diff --git a/src/app/core/cache/models/items/normalized-relationship.model.ts b/src/app/core/cache/models/items/normalized-relationship.model.ts
new file mode 100644
index 0000000000..b908426361
--- /dev/null
+++ b/src/app/core/cache/models/items/normalized-relationship.model.ts
@@ -0,0 +1,57 @@
+import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
+import { Relationship } from '../../../shared/item-relationships/relationship.model';
+import { ResourceType } from '../../../shared/resource-type';
+import { mapsTo, relationship } from '../../builders/build-decorators';
+import { NormalizedObject } from '../normalized-object.model';
+import { IDToUUIDSerializer } from '../../id-to-uuid-serializer';
+
+/**
+ * Normalized model class for a DSpace Relationship
+ */
+@mapsTo(Relationship)
+@inheritSerialization(NormalizedObject)
+export class NormalizedRelationship extends NormalizedObject {
+
+ /**
+ * The identifier of this Relationship
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The identifier of the Item to the left side of this Relationship
+ */
+ @autoserialize
+ leftId: string;
+
+ /**
+ * The identifier of the Item to the right side of this Relationship
+ */
+ @autoserialize
+ rightId: string;
+
+ /**
+ * The place of the Item to the left side of this Relationship
+ */
+ @autoserialize
+ leftPlace: number;
+
+ /**
+ * The place of the Item to the right side of this Relationship
+ */
+ @autoserialize
+ rightPlace: number;
+
+ /**
+ * The type of Relationship
+ */
+ @autoserialize
+ @relationship(ResourceType.RelationshipType, false)
+ relationshipType: string;
+
+ /**
+ * The universally unique identifier of this Relationship
+ */
+ @autoserializeAs(new IDToUUIDSerializer(ResourceType.Relationship), 'id')
+ uuid: string;
+}
diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts
index 9e8c034e81..d2b7b9c92d 100644
--- a/src/app/core/cache/models/normalized-item.model.ts
+++ b/src/app/core/cache/models/normalized-item.model.ts
@@ -63,4 +63,8 @@ export class NormalizedItem extends NormalizedDSpaceObject- {
@relationship(ResourceType.Bitstream, true)
bitstreams: string[];
+ @autoserialize
+ @relationship(ResourceType.Relationship, true)
+ relationships: string[];
+
}
diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts
index 53d7f475fc..aa1f6f2958 100644
--- a/src/app/core/cache/models/normalized-object-factory.ts
+++ b/src/app/core/cache/models/normalized-object-factory.ts
@@ -1,3 +1,6 @@
+import { NormalizedItemType } from './items/normalized-item-type.model';
+import { NormalizedRelationshipType } from './items/normalized-relationship-type.model';
+import { NormalizedRelationship } from './items/normalized-relationship.model';
import { NormalizedBitstream } from './normalized-bitstream.model';
import { NormalizedBundle } from './normalized-bundle.model';
import { NormalizedItem } from './normalized-item.model';
@@ -12,6 +15,8 @@ import { NormalizedWorkspaceItem } from '../../submission/models/normalized-work
import { NormalizedEPerson } from '../../eperson/models/normalized-eperson.model';
import { NormalizedGroup } from '../../eperson/models/normalized-group.model';
import { NormalizedWorkflowItem } from '../../submission/models/normalized-workflowitem.model';
+import { NormalizedClaimedTask } from '../../tasks/models/normalized-claimed-task-object.model';
+import { NormalizedPoolTask } from '../../tasks/models/normalized-pool-task-object.model';
import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model';
import { NormalizedMetadataSchema } from '../../metadata/normalized-metadata-schema.model';
import { CacheableObject } from '../object-cache.reducer';
@@ -46,6 +51,15 @@ export class NormalizedObjectFactory {
case ResourceType.ResourcePolicy: {
return NormalizedResourcePolicy
}
+ case ResourceType.Relationship: {
+ return NormalizedRelationship
+ }
+ case ResourceType.RelationshipType: {
+ return NormalizedRelationshipType
+ }
+ case ResourceType.ItemType: {
+ return NormalizedItemType
+ }
case ResourceType.EPerson: {
return NormalizedEPerson
}
@@ -64,6 +78,12 @@ export class NormalizedObjectFactory {
case ResourceType.Workflowitem: {
return NormalizedWorkflowItem
}
+ case ResourceType.ClaimedTask: {
+ return NormalizedClaimedTask
+ }
+ case ResourceType.PoolTask: {
+ return NormalizedPoolTask
+ }
case ResourceType.SubmissionDefinition:
case ResourceType.SubmissionDefinitions: {
return NormalizedSubmissionDefinitionsModel
diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts
index eae7c06be7..20e12108ad 100644
--- a/src/app/core/cache/object-cache.service.spec.ts
+++ b/src/app/core/cache/object-cache.service.spec.ts
@@ -1,18 +1,19 @@
+import * as ngrx from '@ngrx/store';
import { Store } from '@ngrx/store';
import { of as observableOf } from 'rxjs';
import { ObjectCacheService } from './object-cache.service';
import {
AddPatchObjectCacheAction,
- AddToObjectCacheAction, ApplyPatchObjectCacheAction,
+ AddToObjectCacheAction,
+ ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { CoreState } from '../core.reducers';
import { ResourceType } from '../shared/resource-type';
import { NormalizedItem } from './models/normalized-item.model';
import { first } from 'rxjs/operators';
-import * as ngrx from '@ngrx/store';
-import { Operation } from '../../../../node_modules/fast-json-patch';
+import { Operation } from 'fast-json-patch';
import { RestRequestMethod } from '../data/rest-request-method';
import { AddToSSBAction } from './server-sync-buffer.actions';
import { Patch } from './object-cache.reducer';
diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts
index 483de65b98..e6384571c3 100644
--- a/src/app/core/cache/object-cache.service.ts
+++ b/src/app/core/cache/object-cache.service.ts
@@ -4,7 +4,7 @@ import { applyPatch, Operation } from 'fast-json-patch';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
-import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
+import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors';
import { RestRequestMethod } from '../data/rest-request-method';
diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts
index a734eba812..b3523addc5 100644
--- a/src/app/core/cache/response.models.ts
+++ b/src/app/core/cache/response.models.ts
@@ -8,12 +8,12 @@ import { IntegrationModel } from '../integration/models/integration.model';
import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
-import { AuthStatus } from '../auth/models/auth-status.model';
import { MetadataSchema } from '../metadata/metadataschema.model';
import { MetadataField } from '../metadata/metadatafield.model';
import { PaginatedList } from '../data/paginated-list';
import { SubmissionObject } from '../submission/models/submission-object.model';
import { DSpaceObject } from '../shared/dspace-object.model';
+import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model';
/* tslint:disable:max-classes-per-file */
export class RestResponse {
@@ -202,7 +202,7 @@ export class AuthStatusResponse extends RestResponse {
public toCache = false;
constructor(
- public response: AuthStatus,
+ public response: NormalizedAuthStatus,
public statusCode: number,
public statusText: string,
) {
@@ -254,4 +254,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 6e94028a0a..6550435aa3 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -72,6 +72,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';
@@ -80,6 +81,12 @@ import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service';
import { SearchService } from '../+search-page/search-service/search.service';
+import { RoleService } from './roles/role.service';
+import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
+import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service';
+import { ClaimedTaskDataService } from './tasks/claimed-task-data.service';
+import { PoolTaskDataService } from './tasks/pool-task-data.service';
+import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
const IMPORTS = [
CommonModule,
@@ -131,6 +138,7 @@ const PROVIDERS = [
RegistryBitstreamformatsResponseParsingService,
DebugResponseParsingService,
SearchResponseParsingService,
+ MyDSpaceResponseParsingService,
ServerResponseService,
BrowseResponseParsingService,
BrowseEntriesResponseParsingService,
@@ -162,6 +170,11 @@ const PROVIDERS = [
MenuService,
ObjectUpdatesService,
SearchService,
+ MyDSpaceGuard,
+ RoleService,
+ TaskResponseParsingService,
+ ClaimedTaskDataService,
+ PoolTaskDataService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
@@ -169,6 +182,7 @@ const PROVIDERS = [
multi: true
},
NotificationsService,
+ 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 6102f930b0..4ede02778c 100644
--- a/src/app/core/data/base-response-parsing.service.ts
+++ b/src/app/core/data/base-response-parsing.service.ts
@@ -6,9 +6,8 @@ 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';
+
/* tslint:disable:max-classes-per-file */
export abstract class BaseResponseParsingService {
@@ -26,7 +25,6 @@ export abstract class BaseResponseParsingService {
} else if (Array.isArray(data)) {
return this.processArray(data, requestUUID);
} else if (isRestDataObject(data)) {
- data = this.fixBadEPersonRestResponse(data);
const object = this.deserialize(data);
if (isNotEmpty(data._embedded)) {
Object
@@ -67,7 +65,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);
@@ -142,22 +142,7 @@ export abstract class BaseResponseParsingService {
return this.toCache ? obj.self : obj;
}
- // TODO Remove when https://jira.duraspace.org/browse/DS-4006 is fixed
- // See https://github.com/DSpace/dspace-angular/issues/292
- private fixBadEPersonRestResponse(obj: any): any {
- if (obj.type === ResourceType.EPerson) {
- const groups = obj.groups;
- const normGroups = [];
- if (isNotEmpty(groups)) {
- groups.forEach((group) => {
- const parts = ['eperson', 'groups', group.uuid];
- const href = new RESTURLCombiner(this.EnvConfig, ...parts).toString();
- normGroups.push(href);
- }
- )
- }
- return Object.assign({}, obj, { groups: normGroups });
- }
- return obj;
+ protected 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 3d03b9397d..993954a360 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 {
@@ -34,4 +40,21 @@ export class CollectionDataService extends ComColDataService {
super();
}
+ /**
+ * Find whether there is a collection whom user has authorization to submit to
+ *
+ * @return boolean
+ * true if the user has at least one collection to submit to
+ */
+ hasAuthorizedCollection(): Observable {
+ const searchHref = 'findAuthorized';
+ const options = new FindAllOptions();
+ options.elementsPerPage = 1;
+
+ return this.searchBy(searchHref, options).pipe(
+ filter((collections: RemoteData>) => !collections.isResponsePending),
+ take(1),
+ map((collections: RemoteData>) => collections.payload.totalElements > 0)
+ );
+ }
}
diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts
index 75ef58b06b..8db4d762eb 100644
--- a/src/app/core/data/community-data.service.ts
+++ b/src/app/core/data/community-data.service.ts
@@ -1,9 +1,8 @@
-import { filter, mergeMap, take } from 'rxjs/operators';
+import { filter, take } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
-import { NormalizedCommunity } from '../cache/models/normalized-community.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
import { Community } from '../shared/community.model';
@@ -12,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 4a244db24f..dede6f8ae2 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 1fd207d2bf..cd30479f6d 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 0000000000..d81ce4b6bd
--- /dev/null
+++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts
@@ -0,0 +1,36 @@
+import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service';
+import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
+import { GenericConstructor } from '../shared/generic-constructor';
+import { ResponseParsingService } from './parsing.service';
+import { GetRequest } from './request.models';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { FilteredDiscoveryQueryResponse } from '../cache/response.models';
+
+describe('FilteredDiscoveryPageResponseParsingService', () => {
+ let service: FilteredDiscoveryPageResponseParsingService;
+
+ beforeEach(() => {
+ service = new FilteredDiscoveryPageResponseParsingService(undefined, getMockObjectCacheService());
+ });
+
+ describe('parse', () => {
+ const request = Object.assign(new GetRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/path'), {
+ getResponseParser(): GenericConstructor {
+ return FilteredDiscoveryPageResponseParsingService;
+ }
+ });
+
+ const mockResponse = {
+ payload: {
+ 'discovery-query': 'query'
+ },
+ statusCode: 200,
+ statusText: 'OK'
+ } as DSpaceRESTV2Response;
+
+ it('should return a FilteredDiscoveryQueryResponse containing the correct query', () => {
+ const response = service.parse(request, mockResponse);
+ expect((response as FilteredDiscoveryQueryResponse).filterQuery).toBe(mockResponse.payload['discovery-query']);
+ })
+ });
+});
diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts
new file mode 100644
index 0000000000..166a915b16
--- /dev/null
+++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts
@@ -0,0 +1,35 @@
+import { Inject, Injectable } from '@angular/core';
+import { ResponseParsingService } from './parsing.service';
+import { RestRequest } from './request.models';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { BaseResponseParsingService } from './base-response-parsing.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { GlobalConfig } from '../../../config/global-config.interface';
+import { GLOBAL_CONFIG } from '../../../config';
+import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models';
+
+/**
+ * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a discovery query (string)
+ * wrapped in a FilteredDiscoveryQueryResponse
+ */
+@Injectable()
+export class FilteredDiscoveryPageResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
+ objectFactory = {};
+ toCache = false;
+ constructor(
+ @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
+ protected objectCache: ObjectCacheService,
+ ) { super();
+ }
+
+ /**
+ * Parses data from the REST API to a discovery query wrapped in a FilteredDiscoveryQueryResponse
+ * @param {RestRequest} request
+ * @param {DSpaceRESTV2Response} data
+ * @returns {RestResponse}
+ */
+ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
+ const query = data.payload['discovery-query'];
+ return new FilteredDiscoveryQueryResponse(query, data.statusCode, data.statusText);
+ }
+}
diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts
new file mode 100644
index 0000000000..a6945e27b4
--- /dev/null
+++ b/src/app/core/data/mydspace-response-parsing.service.ts
@@ -0,0 +1,82 @@
+import { Injectable } from '@angular/core';
+import { RestResponse, SearchSuccessResponse } from '../cache/response.models';
+import { DSOResponseParsingService } from './dso-response-parsing.service';
+import { ResponseParsingService } from './parsing.service';
+import { RestRequest } from './request.models';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
+import { hasValue } from '../../shared/empty.util';
+import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model';
+import { MetadataMap, MetadataValue } from '../shared/metadata.models';
+
+@Injectable()
+export class MyDSpaceResponseParsingService implements ResponseParsingService {
+ constructor(private dsoParser: DSOResponseParsingService) {
+ }
+
+ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
+ // fallback for unexpected empty response
+ const emptyPayload = {
+ _embedded: {
+ objects: []
+ }
+ };
+ const payload = data.payload._embedded.searchResult || emptyPayload;
+ const hitHighlights: MetadataMap[] = payload._embedded.objects
+ .map((object) => object.hitHighlights)
+ .map((hhObject) => {
+ const mdMap: MetadataMap = {};
+ if (hhObject) {
+ for (const key of Object.keys(hhObject)) {
+ const value: MetadataValue = Object.assign(new MetadataValue(), {
+ value: hhObject[key].join('...'),
+ language: null
+ });
+ mdMap[key] = [value];
+ }
+ }
+ return mdMap;
+ });
+
+ const dsoSelfLinks = payload._embedded.objects
+ .filter((object) => hasValue(object._embedded))
+ .map((object) => object._embedded.indexableObject)
+ .map((dso) => this.dsoParser.parse(request, {
+ payload: dso,
+ statusCode: data.statusCode,
+ statusText: data.statusText
+ }))
+ .map((obj) => obj.resourceSelfLinks)
+ .reduce((combined, thisElement) => [...combined, ...thisElement], []);
+
+ const objects = payload._embedded.objects
+ .filter((object) => hasValue(object._embedded))
+ .map((object, index) => Object.assign({}, object, {
+ indexableObject: dsoSelfLinks[index],
+ hitHighlights: hitHighlights[index],
+ _embedded: this.filterEmbeddedObjects(object)
+ }));
+ payload.objects = objects;
+ const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload);
+ return new SearchSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(payload));
+ }
+
+ protected filterEmbeddedObjects(object) {
+ const allowedEmbeddedKeys = ['submitter', 'item', 'workspaceitem', 'workflowitem'];
+ if (object._embedded.indexableObject && object._embedded.indexableObject._embedded) {
+ return Object.assign({}, object._embedded, {
+ indexableObject: Object.assign({}, object._embedded.indexableObject, {
+ _embedded: Object.keys(object._embedded.indexableObject._embedded)
+ .filter((key) => allowedEmbeddedKeys.includes(key))
+ .reduce((obj, key) => {
+ obj[key] = object._embedded.indexableObject._embedded[key];
+ return obj;
+ }, {})
+ })
+ });
+ } else {
+ return object;
+ }
+
+ }
+}
diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts
index a13fb9487b..22d5fd3e77 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 faecd231bc..e1c1b22569 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 {
@@ -11,7 +11,7 @@ export class PaginatedList {
if (hasValue(this.pageInfo) && hasValue(this.pageInfo.elementsPerPage)) {
return this.pageInfo.elementsPerPage;
}
- return this.page.length;
+ return this.getPageLength();
}
set elementsPerPage(value: number) {
@@ -22,10 +22,7 @@ export class PaginatedList {
if (hasValue(this.pageInfo) && hasValue(this.pageInfo.totalElements)) {
return this.pageInfo.totalElements;
}
- if (hasNoValue(this.page)) {
- return 0;
- }
- return this.page.length;
+ return this.getPageLength();
}
set totalElements(value: number) {
@@ -92,4 +89,8 @@ export class PaginatedList {
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 d2cdd45a0a..a2b3423960 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 {
+ return TaskResponseParsingService;
+ }
+}
+
+export class TaskDeleteRequest extends DeleteRequest {
+ constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
+ super(uuid, href, body, options);
+ }
+
+ getResponseParser(): GenericConstructor {
+ return TaskResponseParsingService;
+ }
+}
+
+export class MyDSpaceRequest extends GetRequest {
+ public responseMsToLive = 0;
+}
+
export class RequestError extends Error {
statusCode: number;
statusText: string;
diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts
index fd463047f1..83071382ed 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, 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';
@@ -47,7 +49,6 @@ const entryFromUUIDSelector = (uuid: string): MemoizedSelector {
+ // Headers break after being retrieved from the store (because of lazy initialization)
+ // Combining them with a new object fixes this issue
+ if (hasValue(entry) && hasValue(entry.request) && hasValue(entry.request.options) && hasValue(entry.request.options.headers)) {
+ entry = cloneDeep(entry);
+ entry.request.options.headers = Object.assign(new HttpHeaders(), entry.request.options.headers)
+ }
+ return entry;
+ })
);
}
@@ -137,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(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 {
@@ -156,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
@@ -222,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 0ca793c5ae..9ab0104393 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.spec.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts
index 18b9090844..a7aba56a3b 100644
--- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts
+++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts
@@ -1,8 +1,10 @@
import { TestBed, inject } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
-import { DSpaceRESTv2Service } from './dspace-rest-v2.service';
+import { DEFAULT_CONTENT_TYPE, DSpaceRESTv2Service } from './dspace-rest-v2.service';
import { DSpaceObject } from '../shared/dspace-object.model';
+import { RestRequestMethod } from '../data/rest-request-method';
+import { HttpHeaders } from '@angular/common/http';
describe('DSpaceRESTv2Service', () => {
let dSpaceRESTv2Service: DSpaceRESTv2Service;
@@ -47,29 +49,72 @@ describe('DSpaceRESTv2Service', () => {
const req = httpMock.expectOne(url);
expect(req.request.method).toBe('GET');
- req.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText});
+ req.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText });
+ });
+ it('should throw an error', () => {
+ dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => {
+ expect(err).toEqual(mockError);
+ });
+ const req = httpMock.expectOne(url);
+ expect(req.request.method).toBe('GET');
+ req.error(mockError);
+ });
+
+ it('should log an error', () => {
+ spyOn(console, 'log');
+
+ dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => {
+ expect(console.log).toHaveBeenCalled();
+ });
+
+ const req = httpMock.expectOne(url);
+ expect(req.request.method).toBe('GET');
+ req.error(mockError);
+ });
+
+ it('when no content-type header is provided, it should use application/json', () => {
+ dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}).subscribe();
+
+ const req = httpMock.expectOne(url);
+ expect(req.request.headers.get('Content-Type')).toContain(DEFAULT_CONTENT_TYPE);
});
});
- it('should throw an error', () => {
- dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => {
- expect(err).toEqual(mockError);
- });
- const req = httpMock.expectOne(url);
- expect(req.request.method).toBe('GET');
- req.error(mockError);
- });
+ describe('#request', () => {
+ it('should return an Observable', () => {
+ const mockPayload = {
+ page: 1
+ };
+ const mockStatusCode = 200;
+ const mockStatusText = 'GREAT';
- it('should log an error', () => {
- spyOn(console, 'log');
+ dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}).subscribe((response) => {
+ expect(response).toBeTruthy();
+ expect(response.statusCode).toEqual(mockStatusCode);
+ expect(response.statusText).toEqual(mockStatusText);
+ expect(response.payload.page).toEqual(mockPayload.page);
+ });
- dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => {
- expect(console.log).toHaveBeenCalled();
+ const req = httpMock.expectOne(url);
+ expect(req.request.method).toBe('POST');
+ req.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText });
});
- const req = httpMock.expectOne(url);
- expect(req.request.method).toBe('GET');
- req.error(mockError);
+ it('when a content-type header is provided, it should not use application/json', () => {
+ let headers = new HttpHeaders();
+ headers = headers.set('Content-Type', 'text/html');
+ dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}, { headers }).subscribe();
+
+ const req = httpMock.expectOne(url);
+ expect(req.request.headers.get('Content-Type')).not.toContain(DEFAULT_CONTENT_TYPE);
+ });
+
+ it('when no content-type header is provided, it should use application/json', () => {
+ dSpaceRESTv2Service.request(RestRequestMethod.POST, url, {}).subscribe();
+
+ const req = httpMock.expectOne(url);
+ expect(req.request.headers.get('Content-Type')).toContain(DEFAULT_CONTENT_TYPE);
+ });
});
describe('buildFormData', () => {
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 a2a9f2530c..290f4be8a2 100644
--- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
+++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
@@ -1,14 +1,15 @@
-import {throwError as observableThrowError, Observable } from 'rxjs';
-import {catchError, map} from 'rxjs/operators';
+import { Observable, throwError as observableThrowError } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http'
import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model';
import { HttpObserve } from '@angular/common/http/src/client';
import { RestRequestMethod } from '../data/rest-request-method';
-import { isNotEmpty } from '../../shared/empty.util';
+import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { DSpaceObject } from '../shared/dspace-object.model';
+export const DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8';
export interface HttpOptions {
body?: any;
headers?: HttpHeaders;
@@ -38,11 +39,23 @@ export class DSpaceRESTv2Service {
* An Observable containing the response from the server
*/
get(absoluteURL: string): Observable {
- return this.http.get(absoluteURL, { observe: 'response' }).pipe(
- map((res: HttpResponse) => ({ payload: res.body, statusCode: res.status, statusText: res.statusText })),
+ const requestOptions = {
+ observe: 'response' as any,
+ headers: new HttpHeaders({'Content-Type': DEFAULT_CONTENT_TYPE})
+ };
+ return this.http.get(absoluteURL, requestOptions).pipe(
+ map((res: HttpResponse) => ({
+ payload: res.body,
+ statusCode: res.status,
+ statusText: res.statusText
+ })),
catchError((err) => {
console.log('Error: ', err);
- return observableThrowError({statusCode: err.status, statusText: err.statusText, message: err.message});
+ return observableThrowError({
+ statusCode: err.status,
+ statusText: err.statusText,
+ message: err.message
+ });
}));
}
@@ -55,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}
* An Observable containing the response from the server
*/
@@ -65,17 +80,35 @@ export class DSpaceRESTv2Service {
requestOptions.body = this.buildFormData(body);
}
requestOptions.observe = 'response';
- if (options && options.headers) {
- requestOptions.headers = Object.assign(new HttpHeaders(), options.headers);
- }
+
if (options && options.responseType) {
requestOptions.responseType = options.responseType;
}
+
+ if (hasNoValue(options) || hasNoValue(options.headers)) {
+ requestOptions.headers = new HttpHeaders();
+ } else {
+ requestOptions.headers = options.headers;
+ }
+
+ if (!requestOptions.headers.has('Content-Type')) {
+ // Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers
+ requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE);
+ }
return this.http.request(method, url, requestOptions).pipe(
- map((res) => ({ payload: res.body, headers: res.headers, statusCode: res.status, statusText: res.statusText })),
+ map((res) => ({
+ payload: res.body,
+ headers: res.headers,
+ statusCode: res.status,
+ statusText: res.statusText
+ })),
catchError((err) => {
console.log('Error: ', err);
- return observableThrowError({statusCode: err.status, statusText: err.statusText, message: err.message});
+ return observableThrowError({
+ statusCode: err.status,
+ statusText: err.statusText,
+ message: err.message
+ });
}));
}
diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts
index 32286929ee..f8c11c1201 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 a95fc73d33..309dfd8890 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 0000000000..b39d1205a6
--- /dev/null
+++ b/src/app/core/roles/role-types.ts
@@ -0,0 +1,5 @@
+export enum RoleType {
+ Submitter = 'submitter',
+ Controller = 'controller',
+ Admin = 'admin'
+}
diff --git a/src/app/core/roles/role.service.ts b/src/app/core/roles/role.service.ts
new file mode 100644
index 0000000000..7a4b6e6ccf
--- /dev/null
+++ b/src/app/core/roles/role.service.ts
@@ -0,0 +1,70 @@
+import { Injectable } from '@angular/core';
+
+import { Observable, of as observableOf } from 'rxjs';
+import { distinctUntilChanged } from 'rxjs/operators';
+
+import { RoleType } from './role-types';
+import { CollectionDataService } from '../data/collection-data.service';
+
+/**
+ * A service that provides methods to identify user role.
+ */
+@Injectable()
+export class RoleService {
+
+ /**
+ * Initialize instance variables
+ *
+ * @param {CollectionDataService} collectionService
+ */
+ constructor(private collectionService: CollectionDataService) {
+ }
+
+ /**
+ * Check if current user is a submitter
+ */
+ isSubmitter(): Observable {
+ return this.collectionService.hasAuthorizedCollection().pipe(
+ distinctUntilChanged()
+ );
+ }
+
+ /**
+ * Check if current user is a controller
+ */
+ isController(): Observable {
+ // TODO find a way to check if user is a controller
+ return observableOf(true);
+ }
+
+ /**
+ * Check if current user is an admin
+ */
+ isAdmin(): Observable {
+ // TODO find a way to check if user is an admin
+ return observableOf(false);
+ }
+
+ /**
+ * Check if current user by role type
+ *
+ * @param {RoleType} role
+ * the role type
+ */
+ checkRole(role: RoleType): Observable {
+ let check: Observable;
+ switch (role) {
+ case RoleType.Submitter:
+ check = this.isSubmitter();
+ break;
+ case RoleType.Controller:
+ check = this.isController();
+ break;
+ case RoleType.Admin:
+ check = this.isAdmin();
+ break;
+ }
+
+ return check;
+ }
+}
diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts
index 71c6ee7837..063398b339 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 } 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 0000000000..e4f98ab653
--- /dev/null
+++ b/src/app/core/shared/item-relationships/item-type.model.ts
@@ -0,0 +1,27 @@
+import { CacheableObject } from '../../cache/object-cache.reducer';
+import { ResourceType } from '../resource-type';
+
+/**
+ * Describes a type of Item
+ */
+export class ItemType implements CacheableObject {
+ /**
+ * The identifier of this ItemType
+ */
+ id: string;
+
+ /**
+ * The link to the rest endpoint where this object can be found
+ */
+ self: string;
+
+ /**
+ * The type of Resource this is
+ */
+ type: ResourceType;
+
+ /**
+ * The universally unique identifier of this ItemType
+ */
+ uuid: string;
+}
diff --git a/src/app/core/shared/item-relationships/relationship-type.model.ts b/src/app/core/shared/item-relationships/relationship-type.model.ts
new file mode 100644
index 0000000000..404d8cdb4b
--- /dev/null
+++ b/src/app/core/shared/item-relationships/relationship-type.model.ts
@@ -0,0 +1,75 @@
+import { Observable } from 'rxjs';
+import { CacheableObject } from '../../cache/object-cache.reducer';
+import { RemoteData } from '../../data/remote-data';
+import { ResourceType } from '../resource-type';
+import { ItemType } from './item-type.model';
+
+/**
+ * Describes a type of Relationship between multiple possible Items
+ */
+export class RelationshipType implements CacheableObject {
+ /**
+ * The link to the rest endpoint where this object can be found
+ */
+ self: string;
+
+ /**
+ * The type of Resource this is
+ */
+ type: ResourceType;
+
+ /**
+ * The label that describes this RelationshipType
+ */
+ label: string;
+
+ /**
+ * The identifier of this RelationshipType
+ */
+ id: string;
+
+ /**
+ * The universally unique identifier of this RelationshipType
+ */
+ uuid: string;
+
+ /**
+ * The label that describes the Relation to the left of this RelationshipType
+ */
+ leftLabel: string;
+
+ /**
+ * The maximum amount of Relationships allowed to the left of this RelationshipType
+ */
+ leftMaxCardinality: number;
+
+ /**
+ * The minimum amount of Relationships allowed to the left of this RelationshipType
+ */
+ leftMinCardinality: number;
+
+ /**
+ * The label that describes the Relation to the right of this RelationshipType
+ */
+ rightLabel: string;
+
+ /**
+ * The maximum amount of Relationships allowed to the right of this RelationshipType
+ */
+ rightMaxCardinality: number;
+
+ /**
+ * The minimum amount of Relationships allowed to the right of this RelationshipType
+ */
+ rightMinCardinality: number;
+
+ /**
+ * The type of Item found to the left of this RelationshipType
+ */
+ leftType: Observable>;
+
+ /**
+ * The type of Item found to the right of this RelationshipType
+ */
+ rightType: Observable>;
+}
diff --git a/src/app/core/shared/item-relationships/relationship.model.ts b/src/app/core/shared/item-relationships/relationship.model.ts
new file mode 100644
index 0000000000..df8f04cd8a
--- /dev/null
+++ b/src/app/core/shared/item-relationships/relationship.model.ts
@@ -0,0 +1,55 @@
+import { Observable } from 'rxjs';
+import { CacheableObject } from '../../cache/object-cache.reducer';
+import { RemoteData } from '../../data/remote-data';
+import { ResourceType } from '../resource-type';
+import { RelationshipType } from './relationship-type.model';
+
+/**
+ * Describes a Relationship between two Items
+ */
+export class Relationship implements CacheableObject {
+ /**
+ * The link to the rest endpoint where this object can be found
+ */
+ self: string;
+
+ /**
+ * The type of Resource this is
+ */
+ type: ResourceType;
+
+ /**
+ * The universally unique identifier of this Relationship
+ */
+ uuid: string;
+
+ /**
+ * The identifier of this Relationship
+ */
+ id: string;
+
+ /**
+ * The identifier of the Item to the left side of this Relationship
+ */
+ leftId: string;
+
+ /**
+ * The identifier of the Item to the right side of this Relationship
+ */
+ rightId: string;
+
+ /**
+ * The place of the Item to the left side of this Relationship
+ */
+ leftPlace: number;
+
+ /**
+ * The place of the Item to the right side of this Relationship
+ */
+ rightPlace: number;
+
+ /**
+ * The type of Relationship
+ */
+ relationshipType: Observable>;
+}
diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts
index 6cd5634fd0..839103b9f5 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>>;
+ relationships: Observable>>;
+
/**
* Retrieves the thumbnail of this item
* @returns {Observable} 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} the bitstreams with the given bundleName
+ * TODO now that bitstreams can be paginated this should move to the server
+ * see https://github.com/DSpace/dspace-angular/issues/332
*/
getBitstreamsByBundleName(bundleName: string): Observable {
return this.bitstreams.pipe(
- filter((rd: RemoteData>) => !rd.isResponsePending),
+ filter((rd: RemoteData>) => !rd.isResponsePending && isNotUndefined(rd.payload)),
map((rd: RemoteData>) => 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 0000000000..f31f8617ad
--- /dev/null
+++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts
@@ -0,0 +1,43 @@
+import { MetadataRepresentationType } from '../metadata-representation.model';
+import { ItemMetadataRepresentation, ItemTypeToValue } from './item-metadata-representation.model';
+import { Item } from '../../item.model';
+import { MetadataMap, MetadataValue } from '../../metadata.models';
+
+describe('ItemMetadataRepresentation', () => {
+ const valuePrefix = 'Test value for ';
+ const item = new Item();
+ let itemMetadataRepresentation: ItemMetadataRepresentation;
+ const metadataMap = new MetadataMap();
+ for (const key of Object.keys(ItemTypeToValue)) {
+ metadataMap[ItemTypeToValue[key]] = [Object.assign(new MetadataValue(), {
+ value: `${valuePrefix}${ItemTypeToValue[key]}`
+ })];
+ }
+ item.metadata = metadataMap;
+
+ for (const itemType of Object.keys(ItemTypeToValue)) {
+ describe(`when creating an ItemMetadataRepresentation`, () => {
+ beforeEach(() => {
+ item.metadata['relationship.type'] = [
+ Object.assign(new MetadataValue(), {
+ value: itemType
+ })
+ ];
+
+ itemMetadataRepresentation = Object.assign(new ItemMetadataRepresentation(), item);
+ });
+
+ it('should have a representation type of item', () => {
+ expect(itemMetadataRepresentation.representationType).toEqual(MetadataRepresentationType.Item);
+ });
+
+ it('should return the correct value when calling getValue', () => {
+ expect(itemMetadataRepresentation.getValue()).toEqual(`${valuePrefix}${ItemTypeToValue[itemType]}`);
+ });
+
+ it('should return the correct item type', () => {
+ expect(itemMetadataRepresentation.itemType).toEqual(item.firstMetadataValue('relationship.type'));
+ });
+ });
+ }
+});
diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts
new file mode 100644
index 0000000000..7ec1445613
--- /dev/null
+++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts
@@ -0,0 +1,46 @@
+import { Item } from '../../item.model';
+import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model';
+import { hasValue } from '../../../../shared/empty.util';
+
+/**
+ * An object to convert item types into the metadata field it should render for the item's value
+ */
+export const ItemTypeToValue = {
+ Default: 'dc.title',
+ Person: 'dc.contributor.author',
+ OrgUnit: 'dc.title'
+};
+
+/**
+ * This class determines which fields to use when rendering an Item as a metadata value.
+ */
+export class ItemMetadataRepresentation extends Item implements MetadataRepresentation {
+
+ /**
+ * The type of item this item can be represented as
+ */
+ get itemType(): string {
+ return this.firstMetadataValue('relationship.type');
+ }
+
+ /**
+ * Fetch the way this item should be rendered as in a list
+ */
+ get representationType(): MetadataRepresentationType {
+ return MetadataRepresentationType.Item;
+ }
+
+ /**
+ * Get the value to display, depending on the itemType
+ */
+ getValue(): string {
+ let metadata;
+ if (hasValue(ItemTypeToValue[this.itemType])) {
+ metadata = ItemTypeToValue[this.itemType];
+ } else {
+ metadata = ItemTypeToValue.Default;
+ }
+ return this.firstMetadataValue(metadata);
+ }
+
+}
diff --git a/src/app/core/shared/metadata-representation/metadata-representation.model.ts b/src/app/core/shared/metadata-representation/metadata-representation.model.ts
new file mode 100644
index 0000000000..58e5bf906f
--- /dev/null
+++ b/src/app/core/shared/metadata-representation/metadata-representation.model.ts
@@ -0,0 +1,31 @@
+/**
+ * An Enum defining the representation type of metadata
+ */
+export enum MetadataRepresentationType {
+ None = 'none',
+ Item = 'item',
+ AuthorityControlled = 'authority_controlled',
+ PlainText = 'plain_text'
+}
+
+/**
+ * An interface containing information about how we should represent certain metadata
+ */
+export interface MetadataRepresentation {
+ /**
+ * The type of item this metadata is representing
+ * e.g. 'Person'
+ * This can be used for template matching
+ */
+ itemType: string;
+
+ /**
+ * How we should render the metadata in a list
+ */
+ representationType: MetadataRepresentationType,
+
+ /**
+ * Fetches the value to be displayed
+ */
+ getValue(): string
+}
diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts
new file mode 100644
index 0000000000..ea48d345c5
--- /dev/null
+++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts
@@ -0,0 +1,54 @@
+import { MetadatumRepresentation } from './metadatum-representation.model';
+import { MetadataRepresentationType } from '../metadata-representation.model';
+import { MetadataValue } from '../../metadata.models';
+
+describe('MetadatumRepresentation', () => {
+ const itemType = 'Person';
+ const normalMetadatum = Object.assign(new MetadataValue(), {
+ key: 'dc.contributor.author',
+ value: 'Test Author'
+ });
+ const authorityMetadatum = Object.assign(new MetadataValue(), {
+ key: 'dc.contributor.author',
+ value: 'Test Authority Author',
+ authority: '1234'
+ });
+
+ let metadatumRepresentation: MetadatumRepresentation;
+
+ describe('when creating a MetadatumRepresentation based on a standard Metadatum object', () => {
+ beforeEach(() => {
+ metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), normalMetadatum);
+ });
+
+ it('should have a representation type of plain text', () => {
+ expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.PlainText);
+ });
+
+ it('should return the correct value when calling getPrimaryValue', () => {
+ expect(metadatumRepresentation.getValue()).toEqual(normalMetadatum.value);
+ });
+
+ it('should return the correct item type', () => {
+ expect(metadatumRepresentation.itemType).toEqual(itemType);
+ });
+ });
+
+ describe('when creating a MetadatumRepresentation based on an authority controlled Metadatum object', () => {
+ beforeEach(() => {
+ metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), authorityMetadatum);
+ });
+
+ it('should have a representation type of plain text', () => {
+ expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.AuthorityControlled);
+ });
+
+ it('should return the correct value when calling getValue', () => {
+ expect(metadatumRepresentation.getValue()).toEqual(authorityMetadatum.value);
+ });
+
+ it('should return the correct item type', () => {
+ expect(metadatumRepresentation.itemType).toEqual(itemType);
+ });
+ });
+});
diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts
new file mode 100644
index 0000000000..595147f3e6
--- /dev/null
+++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts
@@ -0,0 +1,38 @@
+import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model';
+import { hasValue } from '../../../../shared/empty.util';
+import { MetadataValue } from '../../metadata.models';
+
+/**
+ * This class defines the way the metadatum it extends should be represented
+ */
+export class MetadatumRepresentation extends MetadataValue implements MetadataRepresentation {
+
+ /**
+ * The type of item this metadatum can be represented as
+ */
+ itemType: string;
+
+ constructor(itemType: string) {
+ super();
+ this.itemType = itemType;
+ }
+
+ /**
+ * Fetch the way this metadatum should be rendered as in a list
+ */
+ get representationType(): MetadataRepresentationType {
+ if (hasValue(this.authority)) {
+ return MetadataRepresentationType.AuthorityControlled;
+ } else {
+ return MetadataRepresentationType.PlainText;
+ }
+ }
+
+ /**
+ * Get the value to display
+ */
+ getValue(): string {
+ return this.value;
+ }
+
+}
diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts
index ab007c15f6..9c7e30dcb4 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 7fbea14b13..1e1d7f86d5 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 938d646a82..62a1957e22 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 2eb47507b2..564b0ff319 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 ce9740a0fc..ae46691e39 100644
--- a/src/app/core/shared/operators.ts
+++ b/src/app/core/shared/operators.ts
@@ -1,5 +1,5 @@
import { Observable } from 'rxjs';
-import { filter, find, flatMap, map, tap } from 'rxjs/operators';
+import { filter, find, flatMap, map, take, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
import { RemoteData } from '../data/remote-data';
@@ -10,6 +10,8 @@ import { BrowseDefinition } from './browse-definition.model';
import { DSpaceObject } from './dspace-object.model';
import { PaginatedList } from '../data/paginated-list';
import { SearchResult } from '../../+search-page/search-result.model';
+import { Item } from './item.model';
+import { Router } from '@angular/router';
/**
* This file contains custom RxJS operators that can be used in multiple places
@@ -62,6 +64,20 @@ export const getSucceededRemoteData = () =>
(source: Observable>): Observable> =>
source.pipe(find((rd: RemoteData) => rd.hasSucceeded));
+/**
+ * Operator that checks if a remote data object contains a page not found error
+ * When it does contain such an error, it will redirect the user to a page not found, without altering the current URL
+ * @param router The router used to navigate to a new page
+ */
+export const redirectToPageNotFoundOn404 = (router: Router) =>
+ (source: Observable>): Observable> =>
+ source.pipe(
+ tap((rd: RemoteData) => {
+ if (rd.hasFailed && rd.error.statusCode === 404) {
+ router.navigateByUrl('/404', { skipLocationChange: true });
+ }
+ }));
+
export const getFinishedRemoteData = () =>
(source: Observable>): Observable> =>
source.pipe(find((rd: RemoteData) => !rd.isLoading));
@@ -75,7 +91,7 @@ export const toDSpaceObjectListRD = () =>
source.pipe(
filter((rd: RemoteData>>) => rd.hasSucceeded),
map((rd: RemoteData>>) => {
- const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.dspaceObject);
+ const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.indexableObject);
const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList;
return Object.assign(rd, { payload: payload });
})
diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts
index 484f1ea6e2..bdcc3a52f6 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',
@@ -20,4 +23,6 @@ export enum ResourceType {
SubmissionForms = 'submissionforms',
SubmissionSections = 'submissionsections',
SubmissionSection = 'submissionsection',
+ 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 b026d68431..9c8d086097 100644
--- a/src/app/core/shared/view-mode.model.ts
+++ b/src/app/core/shared/view-mode.model.ts
@@ -4,5 +4,6 @@
export enum ViewMode {
List = 'list',
- Grid = 'grid'
+ Grid = 'grid',
+ Detail = 'detail'
}
diff --git a/src/app/core/submission/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts
index 8091781760..f674ebdf72 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 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 6b2d9a03b9..23f75553c5 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 20dfb43cbd..21135be463 100644
--- a/src/app/core/submission/submission-response-parsing.service.ts
+++ b/src/app/core/submission/submission-response-parsing.service.ts
@@ -27,16 +27,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
@@ -49,14 +49,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({});
@@ -93,11 +93,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(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(
@@ -141,9 +140,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 0000000000..a7be0830ec
--- /dev/null
+++ b/src/app/core/tasks/claimed-task-data.service.spec.ts
@@ -0,0 +1,108 @@
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+
+import { Store } from '@ngrx/store';
+
+import { getMockRequestService } from '../../shared/mocks/mock-request.service';
+import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
+import { CoreState } from '../core.reducers';
+import { ClaimedTaskDataService } from './claimed-task-data.service';
+import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
+
+describe('ClaimedTaskDataService', () => {
+ let service: ClaimedTaskDataService;
+ let options: HttpOptions;
+ const taskEndpoint = 'https://rest.api/task';
+ const linkPath = 'claimedtasks';
+ const requestService: any = getMockRequestService();
+ const halService: any = new HALEndpointServiceStub(taskEndpoint);
+ const rdbService = {} as RemoteDataBuildService;
+ const notificationsService = {} as NotificationsService;
+ const http = {} as HttpClient;
+ const comparator = {} as any;
+ const dataBuildService = {
+ normalize: (object) => object
+ } as NormalizedObjectBuildService;
+ const objectCache = {
+ addPatch: () => {
+ /* empty */
+ },
+ getObjectBySelfLink: () => {
+ /* empty */
+ }
+ } as any;
+ const store = {} as Store;
+
+ function initTestService(): ClaimedTaskDataService {
+ return new ClaimedTaskDataService(
+ requestService,
+ rdbService,
+ dataBuildService,
+ store,
+ objectCache,
+ halService,
+ notificationsService,
+ http,
+ comparator
+ );
+ }
+
+ beforeEach(() => {
+ service = initTestService();
+ options = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
+ options.headers = headers;
+ });
+
+ describe('approveTask', () => {
+
+ it('should call postToEndpoint method', () => {
+ const scopeId = '1234';
+ const body = {
+ submit_approve: 'true'
+ };
+
+ spyOn(service, 'postToEndpoint');
+ requestService.uriEncodeBody.and.returnValue(body);
+
+ service.approveTask(scopeId);
+
+ expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options);
+ });
+ });
+
+ describe('rejectTask', () => {
+
+ it('should call postToEndpoint method', () => {
+ const scopeId = '1234';
+ const reason = 'test reject';
+ const body = {
+ submit_reject: 'true',
+ reason
+ };
+
+ spyOn(service, 'postToEndpoint');
+ requestService.uriEncodeBody.and.returnValue(body);
+
+ service.rejectTask(reason, scopeId);
+
+ expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options);
+ });
+ });
+
+ describe('returnToPoolTask', () => {
+
+ it('should call deleteById method', () => {
+ const scopeId = '1234';
+
+ spyOn(service, 'deleteById');
+
+ service.returnToPoolTask(scopeId);
+
+ expect(service.deleteById).toHaveBeenCalledWith(linkPath, scopeId, options);
+ });
+ });
+});
diff --git a/src/app/core/tasks/claimed-task-data.service.ts b/src/app/core/tasks/claimed-task-data.service.ts
new file mode 100644
index 0000000000..dda42e1c67
--- /dev/null
+++ b/src/app/core/tasks/claimed-task-data.service.ts
@@ -0,0 +1,106 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+import { Store } from '@ngrx/store';
+import { Observable } from 'rxjs';
+
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { CoreState } from '../core.reducers';
+import { RequestService } from '../data/request.service';
+import { ClaimedTask } from './models/claimed-task-object.model';
+import { TasksService } from './tasks.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
+import { ProcessTaskResponse } from './models/process-task-response';
+
+/**
+ * The service handling all REST requests for ClaimedTask
+ */
+@Injectable()
+export class ClaimedTaskDataService extends TasksService {
+
+ /**
+ * The endpoint link name
+ */
+ protected linkPath = 'claimedtasks';
+
+ /**
+ * When true, a new request is always dispatched
+ */
+ protected forceBypassCache = true;
+
+ /**
+ * Initialize instance variables
+ *
+ * @param {RequestService} requestService
+ * @param {RemoteDataBuildService} rdbService
+ * @param {NormalizedObjectBuildService} dataBuildService
+ * @param {Store} store
+ * @param {ObjectCacheService} objectCache
+ * @param {HALEndpointService} halService
+ * @param {NotificationsService} notificationsService
+ * @param {HttpClient} http
+ * @param {DSOChangeAnalyzer,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: DSOChangeAnalyzer) {
+ super();
+ }
+
+ /**
+ * Make a request to approve the given task
+ *
+ * @param scopeId
+ * The task id
+ * @return {Observable}
+ * Emit the server response
+ */
+ public approveTask(scopeId: string): Observable {
+ const body = {
+ submit_approve: 'true'
+ };
+ return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions());
+ }
+
+ /**
+ * Make a request to reject the given task
+ *
+ * @param reason
+ * The reason of reject
+ * @param scopeId
+ * The task id
+ * @return {Observable}
+ * Emit the server response
+ */
+ public rejectTask(reason: string, scopeId: string): Observable {
+ const body = {
+ submit_reject: 'true',
+ reason
+ };
+ return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions());
+ }
+
+ /**
+ * Make a request to return the given task to the pool
+ *
+ * @param scopeId
+ * The task id
+ * @return {Observable}
+ * Emit the server response
+ */
+ public returnToPoolTask(scopeId: string): Observable {
+ return this.deleteById(this.linkPath, scopeId, this.makeHttpOptions());
+ }
+
+}
diff --git a/src/app/core/tasks/models/claimed-task-object.model.ts b/src/app/core/tasks/models/claimed-task-object.model.ts
new file mode 100644
index 0000000000..212e75ed95
--- /dev/null
+++ b/src/app/core/tasks/models/claimed-task-object.model.ts
@@ -0,0 +1,8 @@
+import { TaskObject } from './task-object.model';
+
+/**
+ * A model class for a ClaimedTask.
+ */
+export class ClaimedTask extends TaskObject {
+
+}
diff --git a/src/app/core/tasks/models/normalized-claimed-task-object.model.ts b/src/app/core/tasks/models/normalized-claimed-task-object.model.ts
new file mode 100644
index 0000000000..c2c3f12bc4
--- /dev/null
+++ b/src/app/core/tasks/models/normalized-claimed-task-object.model.ts
@@ -0,0 +1,39 @@
+import { NormalizedTaskObject } from './normalized-task-object.model';
+import { mapsTo, relationship } from '../../cache/builders/build-decorators';
+import { autoserialize, inheritSerialization } from 'cerialize';
+import { ClaimedTask } from './claimed-task-object.model';
+import { ResourceType } from '../../shared/resource-type';
+
+/**
+ * A normalized model class for a ClaimedTask.
+ */
+@mapsTo(ClaimedTask)
+@inheritSerialization(NormalizedTaskObject)
+export class NormalizedClaimedTask extends NormalizedTaskObject