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 387b2bb48a..804d80b0f2 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -10,10 +10,10 @@ module.exports = { // The REST API server settings. rest: { ssl: true, - host: 'dspace7-entities.atmire.com', + host: 'dspace7.4science.cloud', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/rest/api' + nameSpace: '/dspace-spring-rest/api' }, // Caching settings cache: { 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 ea7ad35325..e9eef8b9b8 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -97,7 +97,7 @@ "uri": "URI", "files": "Files", "collections": "Collections", - "subject": "Keywords", + "subject": "Keywords", "citation": "Citation", "filesection": { "download": "Download", @@ -410,6 +410,7 @@ }, "login": "Log In", "logout": "Log Out", + "mydspace": "MyDSpace", "language": "Language switch", "search": "Search" }, @@ -446,12 +447,64 @@ "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": { "title": "DSpace Angular :: Search", "description": "", "form": { "search": "Search", - "search_dspace": "Search DSpace" + "search_dspace": "Search DSpace", + "search_mydspace": "Search MyDSpace" }, "results": { "head": "Search Results", @@ -471,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", @@ -484,7 +541,11 @@ "f.dateIssued.max": "End date", "f.subject": "Subject", "f.has_content_in_original_bundle": "Has files", - "f.entityType": "Item Type" + "f.entityType": "Item Type", + "f.namedresourcetype": "Status", + "f.dateSubmitted": "Date submitted", + "f.itemtype": "Type", + "f.submitter": "Submitter" }, "filter": { "show-more": "Show more", @@ -516,6 +577,26 @@ "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" } } } @@ -736,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..." }, @@ -927,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/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index 6ba4e8146b..bc0cbb8da6 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -6,13 +6,24 @@ import { PaginatedList } from '../../../core/data/paginated-list'; import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +/** + * This component renders a list of bitstream formats + */ @Component({ selector: 'ds-bitstream-formats', templateUrl: './bitstream-formats.component.html' }) export class BitstreamFormatsComponent { + /** + * A paginated list of bitstream formats to be shown on the page + */ bitstreamFormats: Observable>>; + + /** + * The current pagination configuration for the page + * Currently simply renders all bitstream formats + */ config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'registry-bitstreamformats-pagination', pageSize: 10000 @@ -22,11 +33,18 @@ export class BitstreamFormatsComponent { this.updateFormats(); } + /** + * When the page is changed, make sure to update the list of bitstreams to match the new page + * @param event The page change event + */ onPageChange(event) { this.config.currentPage = event; this.updateFormats(); } + /** + * Method to update the bitstream formats that are shown + */ private updateFormats() { this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config); } diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts index 92d4a7a72a..7358123462 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts @@ -28,7 +28,7 @@ export const MetadataRegistryActionTypes = { /* tslint:disable:max-classes-per-file */ /** - * Used to collapse the sidebar + * Used to edit a metadata schema in the metadata registry */ export class MetadataRegistryEditSchemaAction implements Action { type = MetadataRegistryActionTypes.EDIT_SCHEMA; @@ -41,12 +41,15 @@ export class MetadataRegistryEditSchemaAction implements Action { } /** - * Used to expand the sidebar + * Used to cancel the editing of a metadata schema in the metadata registry */ export class MetadataRegistryCancelSchemaAction implements Action { type = MetadataRegistryActionTypes.CANCEL_EDIT_SCHEMA; } +/** + * Used to select a single metadata schema in the metadata registry + */ export class MetadataRegistrySelectSchemaAction implements Action { type = MetadataRegistryActionTypes.SELECT_SCHEMA; @@ -57,6 +60,9 @@ export class MetadataRegistrySelectSchemaAction implements Action { } } +/** + * Used to deselect a single metadata schema in the metadata registry + */ export class MetadataRegistryDeselectSchemaAction implements Action { type = MetadataRegistryActionTypes.DESELECT_SCHEMA; @@ -67,12 +73,15 @@ export class MetadataRegistryDeselectSchemaAction implements Action { } } +/** + * Used to deselect all metadata schemas in the metadata registry + */ export class MetadataRegistryDeselectAllSchemaAction implements Action { type = MetadataRegistryActionTypes.DESELECT_ALL_SCHEMA; } /** - * Used to collapse the sidebar + * Used to edit a metadata field in the metadata registry */ export class MetadataRegistryEditFieldAction implements Action { type = MetadataRegistryActionTypes.EDIT_FIELD; @@ -85,12 +94,15 @@ export class MetadataRegistryEditFieldAction implements Action { } /** - * Used to expand the sidebar + * Used to cancel the editing of a metadata field in the metadata registry */ export class MetadataRegistryCancelFieldAction implements Action { type = MetadataRegistryActionTypes.CANCEL_EDIT_FIELD; } +/** + * Used to select a single metadata field in the metadata registry + */ export class MetadataRegistrySelectFieldAction implements Action { type = MetadataRegistryActionTypes.SELECT_FIELD; @@ -101,6 +113,9 @@ export class MetadataRegistrySelectFieldAction implements Action { } } +/** + * Used to deselect a single metadata field in the metadata registry + */ export class MetadataRegistryDeselectFieldAction implements Action { type = MetadataRegistryActionTypes.DESELECT_FIELD; @@ -111,6 +126,9 @@ export class MetadataRegistryDeselectFieldAction implements Action { } } +/** + * Used to deselect all metadata fields in the metadata registry + */ export class MetadataRegistryDeselectAllFieldAction implements Action { type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD; } @@ -120,6 +138,7 @@ export class MetadataRegistryDeselectAllFieldAction implements Action { /** * Export a type alias of all actions in this action group * so that reducers can easily compose action types + * These are all the actions to perform on the metadata registry state */ export type MetadataRegistryAction = MetadataRegistryEditSchemaAction diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.effects.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.effects.ts deleted file mode 100644 index 32aa2d27d7..0000000000 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.effects.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Makes sure that if the user navigates to another route, the sidebar is collapsed - */ -import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; -import { filter, map, tap } from 'rxjs/operators'; -import { SearchSidebarCollapseAction } from '../../../+search-page/search-sidebar/search-sidebar.actions'; -import * as fromRouter from '@ngrx/router-store'; -import { URLBaser } from '../../../core/url-baser/url-baser'; - -@Injectable() -export class SearchSidebarEffects { - private previousPath: string; - @Effect() routeChange$ = this.actions$ - .pipe( - ofType(fromRouter.ROUTER_NAVIGATION), - filter((action) => this.previousPath !== this.getBaseUrl(action)), - tap((action) => { - this.previousPath = this.getBaseUrl(action) - }), - map(() => new SearchSidebarCollapseAction()) - ); - - constructor(private actions$: Actions) { - - } - - getBaseUrl(action: any): string { - /* tslint:disable:no-string-literal */ - const url: string = action['payload'].routerState.url; - return new URLBaser(url).toString(); - /* tslint:enable:no-string-literal */ - } - -} diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.spec.ts new file mode 100644 index 0000000000..f23c12c109 --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.spec.ts @@ -0,0 +1,183 @@ +import { + MetadataRegistryCancelFieldAction, + MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, + MetadataRegistryDeselectAllSchemaAction, MetadataRegistryDeselectFieldAction, + MetadataRegistryDeselectSchemaAction, MetadataRegistryEditFieldAction, + MetadataRegistryEditSchemaAction, MetadataRegistrySelectFieldAction, + MetadataRegistrySelectSchemaAction +} from './metadata-registry.actions'; +import { metadataRegistryReducer, MetadataRegistryState } from './metadata-registry.reducers'; +import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; +import { MetadataField } from '../../../core/metadata/metadatafield.model'; + +class NullAction extends MetadataRegistryEditSchemaAction { + type = null; + constructor() { + super(undefined); + } +} + +const schema: MetadataSchema = Object.assign(new MetadataSchema(), + { + id: 'schema-id', + self: 'http://rest.self/schema/dc', + prefix: 'dc', + namespace: 'http://dublincore.org/documents/dcmi-terms/' + }); + +const schema2: MetadataSchema = Object.assign(new MetadataSchema(), + { + id: 'another-schema-id', + self: 'http://rest.self/schema/dcterms', + prefix: 'dcterms', + namespace: 'http://purl.org/dc/terms/' + }); + +const field: MetadataField = Object.assign(new MetadataField(), + { + id: 'author-field-id', + self: 'http://rest.self/field/author', + element: 'contributor', + qualifier: 'author', + scopeNote: 'Author of an item', + schema: schema + }); + +const field2: MetadataField = Object.assign(new MetadataField(), + { + id: 'title-field-id', + self: 'http://rest.self/field/title', + element: 'title', + qualifier: null, + scopeNote: 'Title of an item', + schema: schema + }); + +const initialState: MetadataRegistryState = { + editSchema: null, + selectedSchemas: [], + editField: null, + selectedFields: [] +}; + +const editState: MetadataRegistryState = { + editSchema: schema, + selectedSchemas: [], + editField: field, + selectedFields: [] +}; + +const selectState: MetadataRegistryState = { + editSchema: null, + selectedSchemas: [schema2], + editField: null, + selectedFields: [field2] +}; + +const moreSelectState: MetadataRegistryState = { + editSchema: null, + selectedSchemas: [schema, schema2], + editField: null, + selectedFields: [field, field2] +}; + +describe('metadataRegistryReducer', () => { + + it('should return the current state when no valid actions have been made', () => { + const state = initialState; + const action = new NullAction(); + const newState = metadataRegistryReducer(state, action); + + expect(newState).toEqual(state); + }); + + it('should start with an the initial state', () => { + const state = initialState; + const action = new NullAction(); + const initState = metadataRegistryReducer(undefined, action); + + expect(initState).toEqual(state); + }); + + it('should update the current state to change the editSchema to a new schema when MetadataRegistryEditSchemaAction is dispatched', () => { + const state = editState; + const action = new MetadataRegistryEditSchemaAction(schema2); + const newState = metadataRegistryReducer(state, action); + + expect(newState.editSchema).toEqual(schema2); + }); + + it('should update the current state to remove the editSchema from the state when MetadataRegistryCancelSchemaAction is dispatched', () => { + const state = editState; + const action = new MetadataRegistryCancelSchemaAction(); + const newState = metadataRegistryReducer(state, action); + + expect(newState.editSchema).toEqual(null); + }); + + it('should update the current state to add a given schema to the selectedSchemas when MetadataRegistrySelectSchemaAction is dispatched', () => { + const state = selectState; + const action = new MetadataRegistrySelectSchemaAction(schema); + const newState = metadataRegistryReducer(state, action); + + expect(newState.selectedSchemas).toContain(schema); + expect(newState.selectedSchemas).toContain(schema2); + }); + + it('should update the current state to remove a given schema to the selectedSchemas when MetadataRegistryDeselectSchemaAction is dispatched', () => { + const state = selectState; + const action = new MetadataRegistryDeselectSchemaAction(schema2); + const newState = metadataRegistryReducer(state, action); + + expect(newState.selectedSchemas).toEqual([]); + }); + + it('should update the current state to remove a given schema to the selectedSchemas when MetadataRegistryDeselectAllSchemaAction is dispatched', () => { + const state = selectState; + const action = new MetadataRegistryDeselectAllSchemaAction(); + const newState = metadataRegistryReducer(state, action); + + expect(newState.selectedSchemas).toEqual([]); + }); + + it('should update the current state to change the editField to a new field when MetadataRegistryEditFieldAction is dispatched', () => { + const state = editState; + const action = new MetadataRegistryEditFieldAction(field2); + const newState = metadataRegistryReducer(state, action); + + expect(newState.editField).toEqual(field2); + }); + + it('should update the current state to remove the editField from the state when MetadataRegistryCancelFieldAction is dispatched', () => { + const state = editState; + const action = new MetadataRegistryCancelFieldAction(); + const newState = metadataRegistryReducer(state, action); + + expect(newState.editField).toEqual(null); + }); + + it('should update the current state to add a given field to the selectedFields when MetadataRegistrySelectFieldAction is dispatched', () => { + const state = selectState; + const action = new MetadataRegistrySelectFieldAction(field); + const newState = metadataRegistryReducer(state, action); + + expect(newState.selectedFields).toContain(field); + expect(newState.selectedFields).toContain(field2); + }); + + it('should update the current state to remove a given field to the selectedFields when MetadataRegistryDeselectFieldAction is dispatched', () => { + const state = selectState; + const action = new MetadataRegistryDeselectFieldAction(field2); + const newState = metadataRegistryReducer(state, action); + + expect(newState.selectedFields).toEqual([]); + }); + + it('should update the current state to remove a given field to the selectedFields when MetadataRegistryDeselectAllFieldAction is dispatched', () => { + const state = selectState; + const action = new MetadataRegistryDeselectAllFieldAction(); + const newState = metadataRegistryReducer(state, action); + + expect(newState.selectedFields).toEqual([]); + }); +}); diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts index f335c880ae..d20e3d5bcc 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts @@ -12,8 +12,8 @@ import { import { MetadataField } from '../../../core/metadata/metadatafield.model'; /** - * The auth state. - * @interface State + * The metadata registry state. + * @interface MetadataRegistryState */ export interface MetadataRegistryState { editSchema: MetadataSchema; 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-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts index 4c09428bd5..2da2b45021 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts @@ -2,14 +2,12 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angu import { MetadataSchema } from '../../../../core/metadata/metadataschema.model'; import { DynamicFormControlModel, - DynamicFormGroupModel, DynamicFormLayout, DynamicInputModel } from '@ng-dynamic-forms/core'; import { FormGroup } from '@angular/forms'; import { RegistryService } from '../../../../core/registry/registry.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; -import { Observable } from 'rxjs/internal/Observable'; import { MetadataField } from '../../../../core/metadata/metadatafield.model'; import { take } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; 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/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index 29a3f9ad31..2acd96adb0 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -6,7 +6,6 @@ import { import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list'; import { Item } from '../../core/shared/item.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ActivatedRoute, Router } from '@angular/router'; diff --git a/src/app/+browse-by/browse-by-guard.spec.ts b/src/app/+browse-by/browse-by-guard.spec.ts new file mode 100644 index 0000000000..784f9ea22d --- /dev/null +++ b/src/app/+browse-by/browse-by-guard.spec.ts @@ -0,0 +1,125 @@ +import { first } from 'rxjs/operators'; +import { BrowseByGuard } from './browse-by-guard'; +import { of as observableOf } from 'rxjs'; + +describe('BrowseByGuard', () => { + describe('canActivate', () => { + let guard: BrowseByGuard; + let dsoService: any; + let translateService: any; + + const name = 'An interesting DSO'; + const title = 'Author'; + const field = 'Author'; + const metadata = 'author'; + const metadataField = 'dc.contributor'; + const scope = '1234-65487-12354-1235'; + const value = 'Filter'; + + beforeEach(() => { + dsoService = { + findById: (id: string) => observableOf({ payload: { name: name }, hasSucceeded: true }) + }; + + translateService = { + instant: () => field + }; + guard = new BrowseByGuard(dsoService, translateService); + }); + + it('should return true, and sets up the data correctly, with a scope and value', () => { + const scopedRoute = { + data: { + title: field, + metadataField, + }, + params: { + metadata, + }, + queryParams: { + scope, + value + } + }; + guard.canActivate(scopedRoute as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => { + const result = { + title, + metadata, + metadataField, + collection: name, + field, + value: '"' + value + '"' + }; + expect(scopedRoute.data).toEqual(result); + expect(canActivate).toEqual(true); + } + ); + }); + + it('should return true, and sets up the data correctly, with a scope and without value', () => { + const scopedNoValueRoute = { + data: { + title: field, + metadataField, + }, + params: { + metadata, + }, + queryParams: { + scope + } + }; + + guard.canActivate(scopedNoValueRoute as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => { + const result = { + title, + metadata, + metadataField, + collection: name, + field, + value: '' + }; + expect(scopedNoValueRoute.data).toEqual(result); + expect(canActivate).toEqual(true); + } + ); + }); + + it('should return true, and sets up the data correctly, without a scope and with a value', () => { + const route = { + data: { + title: field, + metadataField, + }, + params: { + metadata, + }, + queryParams: { + value + } + }; + guard.canActivate(route as any, undefined) + .pipe(first()) + .subscribe( + (canActivate) => { + const result = { + title, + metadata, + metadataField, + collection: '', + field, + value: '"' + value + '"' + }; + expect(route.data).toEqual(result); + expect(canActivate).toEqual(true); + } + ); + }); + }); +}); diff --git a/src/app/+browse-by/browse-by-guard.ts b/src/app/+browse-by/browse-by-guard.ts index 30f5d69ffc..5d3dad2b0f 100644 --- a/src/app/+browse-by/browse-by-guard.ts +++ b/src/app/+browse-by/browse-by-guard.ts @@ -2,10 +2,10 @@ import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angul import { Injectable } from '@angular/core'; import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; import { hasValue } from '../shared/empty.util'; -import { combineLatest as observableCombineLatest } from 'rxjs'; -import { map, take } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { getSucceededRemoteData } from '../core/shared/operators'; import { TranslateService } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; @Injectable() /** @@ -19,29 +19,23 @@ export class BrowseByGuard implements CanActivate { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { const title = route.data.title; - const metadata = route.params.metadata || route.queryParams.metadata || route.data.metadata; + const metadata = route.params.metadata || route.queryParams.metadata || route.data.metadata; const metadataField = route.data.metadataField; const scope = route.queryParams.scope; const value = route.queryParams.value; - - const metadataTranslated$ = this.translate.get('browse.metadata.' + metadata).pipe(take(1)); - + const metadataTranslated = this.translate.instant('browse.metadata.' + metadata); if (hasValue(scope)) { - const dsoAndMetadata$ = observableCombineLatest(metadataTranslated$, this.dsoService.findById(scope).pipe(getSucceededRemoteData())); + const dsoAndMetadata$ = this.dsoService.findById(scope).pipe(getSucceededRemoteData()); return dsoAndMetadata$.pipe( - map(([metadataTranslated, dsoRD]) => { + map((dsoRD) => { const name = dsoRD.payload.name; - route.data = this.createData(title, metadata, metadataField, name, metadataTranslated, value);; + route.data = this.createData(title, metadata, metadataField, name, metadataTranslated, value); return true; }) ); } else { - return metadataTranslated$.pipe( - map((metadataTranslated: string) => { - route.data = this.createData(title, metadata, metadataField, '', metadataTranslated, value); - return true; - }) - ) + route.data = this.createData(title, metadata, metadataField, '', metadataTranslated, value); + return observableOf(true); } } 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 5b158f4a3c..41afbf2115 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,9 +1,11 @@ -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 { ItemDataService } from '../core/data/item-data.service'; import { PaginatedList } from '../core/data/paginated-list'; import { RemoteData } from '../core/data/remote-data'; @@ -11,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 { combineLatest, filter, first, flatMap, map } 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', @@ -32,74 +35,70 @@ 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 itemDataService: ItemDataService, + 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'; this.paginationConfig.pageSize = 5; this.paginationConfig.currentPage = 1; - this.sortConfig = new SortOptions('dc.date.issued', SortDirection.DESC); + this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC); } ngOnInit(): void { this.collectionRD$ = this.route.data.pipe( - map((data) => data.collection), - first() + 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 sortDirection = +params.page || this.sortConfig.direction; - const pagination = Object.assign({}, - this.paginationConfig, - { currentPage: page, pageSize: pageSize } - ); - const sort = Object.assign({}, - this.sortConfig, - { direction: sortDirection, field: this.sortConfig.field } - ); - this.collectionRD$.subscribe((rd: RemoteData) => { - this.collectionId = rd.payload.id; - this.updatePage({ - pagination: pagination, - sort: sort - }); - }); - }) - ); - } - updatePage(searchOptions) { - this.itemRD$ = this.itemDataService.findAll({ - scopeID: this.collectionId, - currentPage: searchOptions.pagination.currentPage, - elementsPerPage: searchOptions.pagination.pageSize, - sort: searchOptions.sort + this.paginationChanges$ = new BehaviorSubject({ + paginationConfig: this.paginationConfig, + sortConfig: this.sortConfig }); - } - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + 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 + ) + ) + ); + + this.route.queryParams.pipe(take(1)).subscribe((params) => { + this.metadata.processRemoteData(this.collectionRD$); + this.onPaginationChange(params); + }) } isNotEmpty(object: any) { @@ -107,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.resolver.spec.ts b/src/app/+collection-page/collection-page.resolver.spec.ts new file mode 100644 index 0000000000..5034b8d369 --- /dev/null +++ b/src/app/+collection-page/collection-page.resolver.spec.ts @@ -0,0 +1,28 @@ +import { first } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; +import { CollectionPageResolver } from './collection-page.resolver'; + +describe('CollectionPageResolver', () => { + describe('resolve', () => { + let resolver: CollectionPageResolver; + let collectionService: any; + const uuid = '1234-65487-12354-1235'; + + beforeEach(() => { + collectionService = { + findById: (id: string) => observableOf({ payload: { id }, hasSucceeded: true }) + }; + resolver = new CollectionPageResolver(collectionService); + }); + + it('should resolve a collection with the correct id', () => { + resolver.resolve({ params: { id: uuid } } as any, undefined) + .pipe(first()) + .subscribe( + (resolved) => { + expect(resolved.payload.id).toEqual(uuid); + } + ); + }); + }); +}); diff --git a/src/app/+collection-page/collection-page.resolver.ts b/src/app/+collection-page/collection-page.resolver.ts index d4835e2e14..8c6e3ad8a6 100644 --- a/src/app/+collection-page/collection-page.resolver.ts +++ b/src/app/+collection-page/collection-page.resolver.ts @@ -4,7 +4,8 @@ import { Collection } from '../core/shared/collection.model'; import { Observable } from 'rxjs'; import { CollectionDataService } from '../core/data/collection-data.service'; import { RemoteData } from '../core/data/remote-data'; -import { getSucceededRemoteData } from '../core/shared/operators'; +import { find } from 'rxjs/operators'; +import { hasValue } from '../shared/empty.util'; /** * This class represents a resolver that requests a specific collection before the route is activated @@ -18,11 +19,12 @@ export class CollectionPageResolver implements Resolve> { * Method for resolving a collection based on the parameters in the current route * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found collection based on the parameters in the current route + * @returns Observable<> Emits the found collection based on the parameters in the current route, + * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { return this.collectionService.findById(route.params.id).pipe( - getSucceededRemoteData() + find((RD) => hasValue(RD.error) || RD.hasSucceeded), ); } } diff --git a/src/app/+collection-page/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 0483143230..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', @@ -21,28 +22,37 @@ import { hasValue } from '../shared/empty.util'; changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) -export class CommunityPageComponent implements OnInit, OnDestroy { +/** + * This component represents a detail page for a single community + */ +export class CommunityPageComponent implements OnInit { + /** + * The community displayed on this page + */ communityRD$: Observable>; - logoRD$: Observable>; - private subs: Subscription[] = []; + /** + * The logo of this community + */ + logoRD$: Observable>; constructor( private communityDataService: CommunityDataService, private metadata: MetadataService, - private route: ActivatedRoute + private route: ActivatedRoute, + private router: Router ) { } ngOnInit(): void { - this.communityRD$ = this.route.data.pipe(map((data) => data.community)); + this.communityRD$ = this.route.data.pipe( + map((data) => data.community as RemoteData), + redirectToPageNotFoundOn404(this.router) + ); this.logoRD$ = this.communityRD$.pipe( map((rd: RemoteData) => rd.payload), filter((community: Community) => hasValue(community)), mergeMap((community: Community) => community.logo)); } - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - } } diff --git a/src/app/+community-page/community-page.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 2287329b37..28e10c5804 100644 --- a/src/app/+home-page/home-news/home-news.component.html +++ b/src/app/+home-page/home-news/home-news.component.html @@ -3,16 +3,17 @@
-

Welcome to DSpace

-

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

+

Welcome to the DSpace 7 Preview

+

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

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

Join an international community of leading institutions using DSpace.

diff --git a/src/app/+home-page/home-news/home-news.component.ts b/src/app/+home-page/home-news/home-news.component.ts index a70e95547d..cebe217623 100644 --- a/src/app/+home-page/home-news/home-news.component.ts +++ b/src/app/+home-page/home-news/home-news.component.ts @@ -5,6 +5,10 @@ import { Component } from '@angular/core'; styleUrls: ['./home-news.component.scss'], templateUrl: './home-news.component.html' }) + +/** + * Component to render the news section on the home page + */ export class HomeNewsComponent { } diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html index 6a3e20ca9d..39ba479033 100644 --- a/src/app/+home-page/home-page.component.html +++ b/src/app/+home-page/home-page.component.html @@ -1,5 +1,5 @@
- +
diff --git a/src/app/+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/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index dd4179a8c8..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,6 +1,6 @@ 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 , BehaviorSubject } from 'rxjs'; @@ -35,8 +35,8 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit { 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 d01363f55c..6743028b6c 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -6,9 +6,7 @@ import { GenericItemPageFieldComponent } from './simple/field-components/specifi 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'; @@ -26,6 +24,8 @@ 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'; +import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; +import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component'; @NgModule({ imports: [ @@ -38,9 +38,7 @@ import { RelatedEntitiesSearchComponent } from './simple/related-entities/relate declarations: [ ItemPageComponent, FullItemPageComponent, - MetadataValuesComponent, MetadataUriValuesComponent, - MetadataFieldWrapperComponent, ItemPageAuthorFieldComponent, ItemPageDateFieldComponent, ItemPageAbstractFieldComponent, @@ -63,7 +61,8 @@ import { RelatedEntitiesSearchComponent } from './simple/related-entities/relate MetadataFieldWrapperComponent, GenericItemPageFieldComponent, RelatedEntitiesSearchComponent, - RelatedItemsComponent + RelatedItemsComponent, + MetadataRepresentationListComponent ], entryComponents: [ PublicationComponent diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index 9743346c3c..4b7ef23b69 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -2,10 +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 { tap } from 'rxjs/operators'; +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 @@ -19,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/item-page.component.spec.ts b/src/app/+item-page/simple/item-page.component.spec.ts index fbe616227f..e1202ab725 100644 --- a/src/app/+item-page/simple/item-page.component.spec.ts +++ b/src/app/+item-page/simple/item-page.component.spec.ts @@ -4,7 +4,7 @@ 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 } from '@angular/router'; +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'; @@ -48,7 +48,8 @@ describe('ItemPageComponent', () => { providers: [ {provide: ActivatedRoute, useValue: mockRoute}, {provide: ItemDataService, useValue: {}}, - {provide: MetadataService, useValue: mockMetadataService} + {provide: MetadataService, useValue: mockMetadataService}, + {provide: Router, useValue: {}} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 69487e7d40..89d5977583 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -1,6 +1,7 @@ -import { filter, map, mergeMap } 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'; @@ -13,6 +14,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'; import { ItemViewMode } from '../../shared/items/item-type-decorator'; /** @@ -39,11 +41,6 @@ export class ItemPageComponent implements OnInit { */ itemRD$: Observable>; - /** - * The item's thumbnail - */ - thumbnail$: Observable; - /** * The view-mode we're currently on */ @@ -51,16 +48,16 @@ export class ItemPageComponent implements OnInit { constructor( private route: ActivatedRoute, + private router: Router, private items: ItemDataService, 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/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts index 321bbc4751..91f7c52bb8 100644 --- 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 @@ -1,10 +1,17 @@ +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 { zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs'; +import { of as observableOf, zip as observableZip, combineLatest as observableCombineLatest } 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 @@ -75,3 +82,41 @@ export const relationsToItems = (thisId: string) => })), 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]; + return observableCombineLatest(matchingRel.leftItem, matchingRel.rightItem).pipe( + map(([leftItem, rightItem]) => { + if (leftItem.payload.id === parentId) { + return rightItem.payload; + } else if (rightItem.payload.id === parentId) { + return leftItem.payload; + } + }), + map((item: Item) => Object.assign(new ItemMetadataRepresentation(), item)) + ); + } + } else { + return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum)); + } + }) + ) + ) + ); diff --git a/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts new file mode 100644 index 0000000000..baf2f0b920 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration-value-type.ts @@ -0,0 +1,4 @@ +export enum MyDSpaceConfigurationValueType { + Workspace = 'workspace', + Workflow = 'workflow' +} diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts new file mode 100644 index 0000000000..38d6769437 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts @@ -0,0 +1,259 @@ +import { of as observableOf } from 'rxjs'; + +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { SearchFilter } from '../+search-page/search-filter.model'; +import { ActivatedRouteStub } from '../shared/testing/active-router-stub'; +import { MockRoleService } from '../shared/mocks/mock-role-service'; +import { cold, hot } from 'jasmine-marbles'; +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; + +describe('MyDSpaceConfigurationService', () => { + let service: MyDSpaceConfigurationService; + const value1 = 'random value'; + const prefixFilter = { + 'f.namedresourcetype': ['another value'], + 'f.dateSubmitted.min': ['2013'], + 'f.dateSubmitted.max': ['2018'] + }; + const defaults = new PaginatedSearchOptions({ + pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }), + sort: new SortOptions('score', SortDirection.DESC), + query: '', + scope: '' + }); + + const backendFilters = [new SearchFilter('f.namedresourcetype', ['another value']), new SearchFilter('f.dateSubmitted', ['[2013 TO 2018]'])]; + + const spy = jasmine.createSpyObj('RouteService', { + getQueryParameterValue: observableOf(value1), + getQueryParamsWithPrefix: observableOf(prefixFilter), + getRouteParameterValue: observableOf(''), + getRouteDataValue: observableOf({}) + }); + + const activatedRoute: any = new ActivatedRouteStub(); + + const roleService: any = new MockRoleService(); + + const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { + getQueryByFilterName: observableOf(''), + }); + + beforeEach(() => { + service = new MyDSpaceConfigurationService(roleService, fixedFilterService, spy, activatedRoute); + }); + + describe('when the scope is called', () => { + beforeEach(() => { + service.getCurrentScope(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'scope\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('scope'); + }); + }); + + describe('when getCurrentConfiguration is called', () => { + beforeEach(() => { + service.getCurrentConfiguration(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'configuration\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('configuration'); + }); + }); + + describe('when getCurrentQuery is called', () => { + beforeEach(() => { + service.getCurrentQuery(''); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'query\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('query'); + }); + }); + + describe('when getCurrentDSOType is called', () => { + beforeEach(() => { + service.getCurrentDSOType(); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'dsoType\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('dsoType'); + }); + }); + + describe('when getCurrentFrontendFilters is called', () => { + beforeEach(() => { + service.getCurrentFrontendFilters(); + }); + it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => { + expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + }); + }); + + describe('when getCurrentFilters is called', () => { + let parsedValues$; + beforeEach(() => { + parsedValues$ = service.getCurrentFilters(); + }); + it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => { + expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.'); + parsedValues$.subscribe((values) => { + expect(values).toEqual(backendFilters); + }); + }); + }); + + describe('when getCurrentSort is called', () => { + beforeEach(() => { + service.getCurrentSort({} as any); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'sortDirection\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortDirection'); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'sortField\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField'); + }); + }); + + describe('when getCurrentPagination is called', () => { + beforeEach(() => { + service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'page\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('page'); + }); + it('should call getQueryParameterValue on the routeService with parameter name \'pageSize\'', () => { + expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize'); + }); + }); + + describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => { + beforeEach(() => { + spyOn(service, 'getCurrentPagination').and.callThrough(); + spyOn(service, 'getCurrentSort').and.callThrough(); + spyOn(service, 'getCurrentScope').and.callThrough(); + spyOn(service, 'getCurrentConfiguration').and.callThrough(); + spyOn(service, 'getCurrentQuery').and.callThrough(); + spyOn(service, 'getCurrentDSOType').and.callThrough(); + spyOn(service, 'getCurrentFilters').and.callThrough(); + }); + + describe('when subscribeToSearchOptions is called', () => { + beforeEach(() => { + (service as any).subscribeToSearchOptions(defaults) + }); + it('should call all getters it needs, but not call any others', () => { + expect(service.getCurrentPagination).not.toHaveBeenCalled(); + expect(service.getCurrentSort).not.toHaveBeenCalled(); + expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); + expect(service.getCurrentQuery).toHaveBeenCalled(); + expect(service.getCurrentDSOType).toHaveBeenCalled(); + expect(service.getCurrentFilters).toHaveBeenCalled(); + }); + }); + + describe('when subscribeToPaginatedSearchOptions is called', () => { + beforeEach(() => { + (service as any).subscribeToPaginatedSearchOptions(defaults); + }); + it('should call all getters it needs', () => { + expect(service.getCurrentPagination).toHaveBeenCalled(); + expect(service.getCurrentSort).toHaveBeenCalled(); + expect(service.getCurrentScope).toHaveBeenCalled(); + expect(service.getCurrentConfiguration).toHaveBeenCalled(); + expect(service.getCurrentQuery).toHaveBeenCalled(); + expect(service.getCurrentDSOType).toHaveBeenCalled(); + expect(service.getCurrentFilters).toHaveBeenCalled(); + }); + }); + }); + + describe('when getAvailableConfigurationTypes is called', () => { + + it('should return properly list when user is submitter', () => { + roleService.setSubmitter(true); + roleService.setController(false); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workspace + ] + })); + }); + + it('should return properly list when user is controller', () => { + roleService.setSubmitter(false); + roleService.setController(true); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + + it('should return properly list when user is admin', () => { + roleService.setSubmitter(false); + roleService.setController(false); + roleService.setAdmin(true); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + + it('should return properly list when user is submitter and controller', () => { + roleService.setSubmitter(true); + roleService.setController(true); + roleService.setAdmin(false); + + const list$ = service.getAvailableConfigurationTypes(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.Workflow + ] + })); + }); + }); + + describe('when getAvailableConfigurationOptions is called', () => { + + it('should return properly options list', () => { + spyOn(service, 'getAvailableConfigurationTypes').and.returnValue(hot('a', { + a: [ + MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.Workflow + ] + })); + + const list$ = service.getAvailableConfigurationOptions(); + + expect(list$).toBeObservable(cold('(b|)', { + b: [ + { + value: MyDSpaceConfigurationValueType.Workspace, + label: `mydspace.show.${MyDSpaceConfigurationValueType.Workspace}` + }, + { + value: MyDSpaceConfigurationValueType.Workflow, + label: `mydspace.show.${MyDSpaceConfigurationValueType.Workflow}` + } + ] + })); + }); + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.ts new file mode 100644 index 0000000000..705ec897f8 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { combineLatest, Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; +import { RoleService } from '../core/roles/role.service'; +import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { RouteService } from '../shared/services/route.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; + +/** + * Service that performs all actions that have to do with the current mydspace configuration + */ +@Injectable() +export class MyDSpaceConfigurationService extends SearchConfigurationService { + /** + * Default pagination settings + */ + protected defaultPagination = Object.assign(new PaginationComponentOptions(), { + id: 'mydspace-page', + pageSize: 10, + currentPage: 1 + }); + + /** + * Default sort settings + */ + protected defaultSort = new SortOptions('dc.date.issued', SortDirection.DESC); + + /** + * Default configuration parameter setting + */ + protected defaultConfiguration = 'workspace'; + + /** + * Default scope setting + */ + protected defaultScope = ''; + + /** + * Default query setting + */ + protected defaultQuery = ''; + + private isAdmin$: Observable; + private isController$: Observable; + private isSubmitter$: Observable; + + /** + * Initialize class + * + * @param {roleService} roleService + * @param {SearchFixedFilterService} fixedFilterService + * @param {RouteService} routeService + * @param {ActivatedRoute} route + */ + constructor(protected roleService: RoleService, + protected fixedFilterService: SearchFixedFilterService, + protected routeService: RouteService, + protected route: ActivatedRoute) { + + super(routeService, fixedFilterService, route); + + // override parent class initialization + this._defaults = null; + this.initDefaults(); + + this.isSubmitter$ = this.roleService.isSubmitter(); + this.isController$ = this.roleService.isController(); + this.isAdmin$ = this.roleService.isAdmin(); + } + + /** + * Returns the list of available configuration depend on the user role + * + * @return {Observable} + * Emits the available configuration list + */ + public getAvailableConfigurationTypes(): Observable { + return combineLatest(this.isSubmitter$, this.isController$, this.isAdmin$).pipe( + first(), + map(([isSubmitter, isController, isAdmin]: [boolean, boolean, boolean]) => { + const availableConf: MyDSpaceConfigurationValueType[] = []; + if (isSubmitter) { + availableConf.push(MyDSpaceConfigurationValueType.Workspace); + } + if (isController || isAdmin) { + availableConf.push(MyDSpaceConfigurationValueType.Workflow); + } + return availableConf; + })); + } + + /** + * Returns the select options for the available configuration list + * + * @return {Observable} + * Emits the select options list + */ + public getAvailableConfigurationOptions(): Observable { + return this.getAvailableConfigurationTypes().pipe( + first(), + map((availableConfigurationTypes: MyDSpaceConfigurationValueType[]) => { + const configurationOptions: SearchConfigurationOption[] = []; + availableConfigurationTypes.forEach((type) => { + const value = type; + const label = `mydspace.show.${value}`; + configurationOptions.push({ value, label }); + }); + return configurationOptions; + }) + ) + } + +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html new file mode 100644 index 0000000000..280d694d27 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -0,0 +1,15 @@ + diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss new file mode 100644 index 0000000000..40a955b349 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.scss @@ -0,0 +1,11 @@ +.parent { + display: flex; +} + +.upload { + flex: auto; +} + +.add { + flex: initial; +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts new file mode 100644 index 0000000000..012f86f579 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -0,0 +1,101 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { Store } from '@ngrx/store'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; + +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +import { AuthService } from '../../core/auth/auth.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { createTestComponent } from '../../shared/testing/utils'; +import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission.component'; +import { AppState } from '../../app.reducer'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { SharedModule } from '../../shared/shared.module'; +import { getMockScrollToService } from '../../shared/mocks/mock-scroll-to-service'; +import { UploaderService } from '../../shared/uploader/uploader.service'; + +describe('MyDSpaceNewSubmissionComponent test', () => { + + const translateService: any = getMockTranslateService(); + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + pipe: observableOf(true) + }); + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + SharedModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + declarations: [ + MyDSpaceNewSubmissionComponent, + TestComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: ScrollToService, useValue: getMockScrollToService() }, + { provide: Store, useValue: store }, + { provide: TranslateService, useValue: translateService }, + ChangeDetectorRef, + MyDSpaceNewSubmissionComponent, + UploaderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create MyDSpaceNewSubmissionComponent', inject([MyDSpaceNewSubmissionComponent], (app: MyDSpaceNewSubmissionComponent) => { + + expect(app).toBeDefined(); + + })); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + reload = (event) => { + return; + } +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts new file mode 100644 index 0000000000..938a1ec899 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -0,0 +1,118 @@ +import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; + +import { Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; + +import { SubmissionState } from '../../submission/submission.reducers'; +import { AuthService } from '../../core/auth/auth.service'; +import { MyDSpaceResult } from '../my-dspace-result.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { NotificationType } from '../../shared/notifications/models/notification-type'; +import { hasValue } from '../../shared/empty.util'; + +/** + * This component represents the whole mydspace page header + */ +@Component({ + selector: 'ds-my-dspace-new-submission', + styleUrls: ['./my-dspace-new-submission.component.scss'], + templateUrl: './my-dspace-new-submission.component.html' +}) +export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { + @Output() uploadEnd = new EventEmitter>>(); + + /** + * The UploaderOptions object + */ + public uploadFilesOptions: UploaderOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + + /** + * Subscription to unsubscribe from + */ + private sub: Subscription; + + /** + * Initialize instance variables + * + * @param {AuthService} authService + * @param {ChangeDetectorRef} changeDetectorRef + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {Store} store + * @param {TranslateService} translate + */ + constructor(private authService: AuthService, + private changeDetectorRef: ChangeDetectorRef, + private halService: HALEndpointService, + private notificationsService: NotificationsService, + private store: Store, + private translate: TranslateService) { + } + + /** + * Initialize url and Bearer token + */ + ngOnInit() { + this.sub = this.halService.getEndpoint('workspaceitems').pipe(first()).subscribe((url) => { + this.uploadFilesOptions.url = url; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + this.changeDetectorRef.detectChanges(); + } + ); + } + + /** + * Method called when file upload is completed to notify upload status + */ + public onCompleteItem(res) { + if (res && res._embedded && res._embedded.workspaceitems && res._embedded.workspaceitems.length > 0) { + const workspaceitems = res._embedded.workspaceitems; + this.uploadEnd.emit(workspaceitems); + + if (workspaceitems.length === 1) { + const options = new NotificationOptions(); + options.timeOut = 0; + const link = '/workspaceitems/' + workspaceitems[0].id + '/edit'; + this.notificationsService.notificationWithAnchor( + NotificationType.Success, + options, + link, + 'mydspace.general.text-here', + 'mydspace.upload.upload-successful', + 'here'); + } else if (workspaceitems.length > 1) { + this.notificationsService.success(null, this.translate.get('mydspace.upload.upload-multiple-successful', {qty: workspaceitems.length})); + } + + } else { + this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); + } + } + + /** + * Method called on file upload error + */ + public onUploadError() { + this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+my-dspace-page/my-dspace-page-routing.module.ts b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts new file mode 100644 index 0000000000..d70a007e3a --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { MyDSpacePageComponent } from './my-dspace-page.component'; +import { MyDSpaceGuard } from './my-dspace.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: MyDSpacePageComponent, + data: { title: 'mydspace.title' }, + canActivate: [ + MyDSpaceGuard + ] + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the mydspace page path. + */ +export class MyDspacePageRoutingModule { +} diff --git a/src/app/+my-dspace-page/my-dspace-page.component.html b/src/app/+my-dspace-page/my-dspace-page.component.html new file mode 100644 index 0000000000..4c691028fc --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.html @@ -0,0 +1,48 @@ +
+ +
+ +
+ + + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.scss b/src/app/+my-dspace-page/my-dspace-page.component.scss new file mode 100644 index 0000000000..86c589bf66 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.scss @@ -0,0 +1 @@ +@import '../+search-page/search-page.component.scss'; diff --git a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts new file mode 100644 index 0000000000..9658814a6a --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts @@ -0,0 +1,204 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; + +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { HostWindowService } from '../shared/host-window.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { RemoteData } from '../core/data/remote-data'; +import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.component'; +import { RouteService } from '../shared/services/route.service'; +import { routeServiceStub } from '../shared/testing/route-service-stub'; +import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; +import { SearchService } from '../+search-page/search-service/search.service'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service'; +import { SearchFilterService } from '../+search-page/search-filters/search-filter/search-filter.service'; +import { RoleDirective } from '../shared/roles/role.directive'; +import { RoleService } from '../core/roles/role.service'; +import { MockRoleService } from '../shared/mocks/mock-role-service'; +import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; + +describe('MyDSpacePageComponent', () => { + let comp: MyDSpacePageComponent; + let fixture: ComponentFixture; + let searchServiceObject: SearchService; + let searchConfigurationServiceObject: SearchConfigurationService; + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + select: observableOf(true) + }); + const pagination: PaginationComponentOptions = new PaginationComponentOptions(); + pagination.id = 'mydspace-results-pagination'; + pagination.currentPage = 1; + pagination.pageSize = 10; + const sort: SortOptions = new SortOptions('score', SortDirection.DESC); + const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); + const searchServiceStub = jasmine.createSpyObj('SearchService', { + search: mockResults, + getSearchLink: '/mydspace', + getScopes: observableOf(['test-scope']), + setServiceOptions: {} + }); + const configurationParam = 'default'; + const queryParam = 'test query'; + const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; + const paginatedSearchOptions = new PaginatedSearchOptions({ + configuration: configurationParam, + query: queryParam, + scope: scopeParam, + pagination, + sort + }); + const activatedRouteStub = { + snapshot: { + queryParamMap: new Map([ + ['query', queryParam], + ['scope', scopeParam] + ]) + }, + queryParams: observableOf({ + query: queryParam, + scope: scopeParam + }) + }; + const sidebarService = { + isCollapsed: observableOf(true), + collapse: () => this.isCollapsed = observableOf(true), + expand: () => this.isCollapsed = observableOf(false) + }; + const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } + } as SearchFixedFilterService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()], + declarations: [MyDSpacePageComponent, RoleDirective], + providers: [ + { provide: SearchService, useValue: searchServiceStub }, + { + provide: CommunityDataService, + useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll']) + }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: RouteService, useValue: routeServiceStub }, + { + provide: Store, useValue: store + }, + { + provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', + { + isXs: observableOf(true), + isSm: observableOf(false), + isXsOrSm: observableOf(true) + }) + }, + { + provide: SearchSidebarService, + useValue: sidebarService + }, + { + provide: SearchFilterService, + useValue: {} + }, { + provide: SEARCH_CONFIG_SERVICE, + useValue: new SearchConfigurationServiceStub() + }, + { + provide: RoleService, + useValue: new MockRoleService() + }, + { + provide: SearchFixedFilterService, + useValue: mockFixedFilterService + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MyDSpacePageComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpacePageComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + fixture.detectChanges(); + searchServiceObject = (comp as any).service; + searchConfigurationServiceObject = (comp as any).searchConfigService; + }); + + afterEach(() => { + comp = null; + searchServiceObject = null; + searchConfigurationServiceObject = null; + }); + + it('should get the scope and query from the route parameters', () => { + + searchConfigurationServiceObject.paginatedSearchOptions.next(paginatedSearchOptions); + expect(comp.searchOptions$).toBeObservable(cold('b', { + b: paginatedSearchOptions + })); + + }); + + describe('when the open sidebar button is clicked in mobile view', () => { + + beforeEach(() => { + spyOn(comp, 'openSidebar'); + const openSidebarButton = fixture.debugElement.query(By.css('.open-sidebar')); + openSidebarButton.triggerEventHandler('click', null); + }); + + it('should trigger the openSidebar function', () => { + expect(comp.openSidebar).toHaveBeenCalled(); + }); + + }); + + describe('when sidebarCollapsed is true in mobile view', () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; + comp.isSidebarCollapsed = () => observableOf(true); + fixture.detectChanges(); + }); + + it('should close the sidebar', () => { + expect(menu.classList).not.toContain('active'); + }); + + }); + + describe('when sidebarCollapsed is false in mobile view', () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; + comp.isSidebarCollapsed = () => observableOf(false); + fixture.detectChanges(); + }); + + it('should open the menu', () => { + expect(menu.classList).toContain('active'); + }); + + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-page.component.ts b/src/app/+my-dspace-page/my-dspace-page.component.ts new file mode 100644 index 0000000000..251bf50bd1 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectionStrategy, + Component, + Inject, + InjectionToken, + Input, + OnInit +} from '@angular/core'; + +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { switchMap, tap, } from 'rxjs/operators'; + +import { PaginatedList } from '../core/data/paginated-list'; +import { RemoteData } from '../core/data/remote-data'; +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { pushInOut } from '../shared/animations/push'; +import { HostWindowService } from '../shared/host-window.service'; +import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchService } from '../+search-page/search-service/search.service'; +import { SearchSidebarService } from '../+search-page/search-sidebar/search-sidebar.service'; +import { hasValue } from '../shared/empty.util'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { MyDSpaceResult } from './my-dspace-result.model'; +import { MyDSpaceResponseParsingService } from '../core/data/mydspace-response-parsing.service'; +import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; +import { RoleType } from '../core/roles/role-types'; +import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; +import { ViewMode } from '../core/shared/view-mode.model'; +import { MyDSpaceRequest } from '../core/data/request.models'; + +export const MYDSPACE_ROUTE = '/mydspace'; +export const SEARCH_CONFIG_SERVICE: InjectionToken = new InjectionToken('searchConfigurationService'); + +/** + * This component represents the whole mydspace page + */ +@Component({ + selector: 'ds-my-dspace-page', + styleUrls: ['./my-dspace-page.component.scss'], + templateUrl: './my-dspace-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: MyDSpaceConfigurationService + } + ] +}) +export class MyDSpacePageComponent implements OnInit { + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch = true; + + /** + * The list of available configuration options + */ + configurationList$: Observable; + + /** + * The current search results + */ + resultsRD$: BehaviorSubject>>> = new BehaviorSubject(null); + + /** + * The current paginated search options + */ + searchOptions$: Observable; + + /** + * The current relevant scopes + */ + scopeListRD$: Observable; + + /** + * Emits true if were on a small screen + */ + isXsOrSm$: Observable; + + /** + * Subscription to unsubscribe from + */ + sub: Subscription; + + /** + * Variable for enumeration RoleType + */ + roleTypeEnum = RoleType; + + /** + * List of available view mode + */ + viewModeList = [ViewMode.List, ViewMode.Detail]; + + constructor(private service: SearchService, + private sidebarService: SearchSidebarService, + private windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: MyDSpaceConfigurationService) { + this.isXsOrSm$ = this.windowService.isXsOrSm(); + this.service.setServiceOptions(MyDSpaceResponseParsingService, MyDSpaceRequest); + } + + /** + * Initialize available configuration list + * + * Listening to changes in the paginated search options + * If something changes, update the search results + * + * Listen to changes in the scope + * If something changes, update the list of scopes for the dropdown + */ + ngOnInit(): void { + this.configurationList$ = this.searchConfigService.getAvailableConfigurationOptions(); + this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; + + this.sub = this.searchOptions$.pipe( + tap(() => this.resultsRD$.next(null)), + switchMap((options: PaginatedSearchOptions) => this.service.search(options).pipe(getSucceededRemoteData()))) + .subscribe((results) => { + this.resultsRD$.next(results); + }); + this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( + switchMap((scopeId) => this.service.getScopes(scopeId)) + ); + + } + + /** + * Set the sidebar to a collapsed state + */ + public closeSidebar(): void { + this.sidebarService.collapse() + } + + /** + * Set the sidebar to an expanded state + */ + public openSidebar(): void { + this.sidebarService.expand(); + } + + /** + * Check if the sidebar is collapsed + * @returns {Observable} emits true if the sidebar is currently collapsed, false if it is expanded + */ + public isSidebarCollapsed(): Observable { + return this.sidebarService.isCollapsed; + } + + /** + * @returns {string} The base path to the search page + */ + public getSearchLink(): string { + return this.service.getSearchLink(); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+my-dspace-page/my-dspace-page.module.ts b/src/app/+my-dspace-page/my-dspace-page.module.ts new file mode 100644 index 0000000000..4b8cf37b7a --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-page.module.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; + +import { MyDspacePageRoutingModule } from './my-dspace-page-routing.module'; +import { MyDSpacePageComponent } from './my-dspace-page.component'; +import { SearchPageModule } from '../+search-page/search-page.module'; +import { MyDSpaceResultsComponent } from './my-dspace-results/my-dspace-results.component'; +import { WorkspaceitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-list-element.component'; +import { ItemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/item-my-dspace-result/item-my-dspace-result-list-element.component'; +import { WorkflowitemMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-list-element.component'; +import { ClaimedMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-my-dspace-result/claimed-my-dspace-result-list-element.component'; +import { PoolMyDSpaceResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/pool-my-dspace-result/pool-my-dspace-result-list-element.component'; +import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission/my-dspace-new-submission.component'; +import { ItemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/item-my-dspace-result/item-my-dspace-result-detail-element.component'; +import { WorkspaceitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workspaceitem-my-dspace-result/workspaceitem-my-dspace-result-detail-element.component'; +import { WorkflowitemMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/workflowitem-my-dspace-result/workflowitem-my-dspace-result-detail-element.component'; +import { ClaimedMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/claimed-my-dspace-result/claimed-my-dspace-result-detail-element.component'; +import { PoolMyDSpaceResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/pool-my-dspace-result/pool-my-dspace-result-detail-lement.component'; +import { MyDSpaceGuard } from './my-dspace.guard'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + MyDspacePageRoutingModule, + SearchPageModule + ], + declarations: [ + MyDSpacePageComponent, + MyDSpaceResultsComponent, + ItemMyDSpaceResultListElementComponent, + WorkspaceitemMyDSpaceResultListElementComponent, + WorkflowitemMyDSpaceResultListElementComponent, + ClaimedMyDSpaceResultListElementComponent, + PoolMyDSpaceResultListElementComponent, + ItemMyDSpaceResultDetailElementComponent, + WorkspaceitemMyDSpaceResultDetailElementComponent, + WorkflowitemMyDSpaceResultDetailElementComponent, + ClaimedMyDSpaceResultDetailElementComponent, + PoolMyDSpaceResultDetailElementComponent, + MyDSpaceNewSubmissionComponent + ], + providers: [ + MyDSpaceGuard, + MyDSpaceConfigurationService + ], + entryComponents: [ + ItemMyDSpaceResultListElementComponent, + WorkspaceitemMyDSpaceResultListElementComponent, + WorkflowitemMyDSpaceResultListElementComponent, + ClaimedMyDSpaceResultListElementComponent, + PoolMyDSpaceResultListElementComponent, + ItemMyDSpaceResultDetailElementComponent, + WorkspaceitemMyDSpaceResultDetailElementComponent, + WorkflowitemMyDSpaceResultDetailElementComponent, + ClaimedMyDSpaceResultDetailElementComponent, + PoolMyDSpaceResultDetailElementComponent + ] +}) + +/** + * This module handles all components that are necessary for the mydspace page + */ +export class MyDSpacePageModule { + +} diff --git a/src/app/+my-dspace-page/my-dspace-result.model.ts b/src/app/+my-dspace-page/my-dspace-result.model.ts new file mode 100644 index 0000000000..d300ed0bc8 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-result.model.ts @@ -0,0 +1,19 @@ +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { MetadataMap } from '../core/shared/metadata.models'; +import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; + +/** + * Represents a search result object of a certain () DSpaceObject + */ +export class MyDSpaceResult implements ListableObject { + /** + * The DSpaceObject that was found + */ + indexableObject: T; + + /** + * The metadata that was used to find this item, hithighlighted + */ + hitHighlights: MetadataMap; + +} diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html new file mode 100644 index 0000000000..132a0d2204 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html @@ -0,0 +1,12 @@ +
+ + +
+ + +

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

diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts new file mode 100644 index 0000000000..67625706a6 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.spec.ts @@ -0,0 +1,58 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { QueryParamsDirectiveStub } from '../../shared/testing/query-params-directive-stub'; +import { MyDSpaceResultsComponent } from './my-dspace-results.component'; + +describe('MyDSpaceResultsComponent', () => { + let comp: MyDSpaceResultsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule], + declarations: [ + MyDSpaceResultsComponent, + QueryParamsDirectiveStub], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpaceResultsComponent); + comp = fixture.componentInstance; // MyDSpaceResultsComponent test instance + }); + + it('should display results when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).not.toBeNull(); + }); + + it('should not display link when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('a'))).toBeNull(); + }); + + it('should display error message if error is != 400', () => { + (comp as any).searchResults = { hasFailed: true, error: { statusCode: 500 } }; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-error'))).not.toBeNull(); + }); + + it('should display a message if search result is empty', () => { + (comp as any).searchResults = { payload: { page: { length: 0 } } }; + (comp as any).searchConfig = { query: 'foobar' }; + fixture.detectChanges(); + + const linkDes = fixture.debugElement.queryAll(By.css('text-muted')); + + expect(linkDes).toBeDefined() + }); +}); diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts new file mode 100644 index 0000000000..3a16def9c1 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts @@ -0,0 +1,51 @@ +import { Component, Input } from '@angular/core'; + +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { MyDSpaceResult } from '../my-dspace-result.model'; +import { SearchOptions } from '../../+search-page/search-options.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { ViewMode } from '../../core/shared/view-mode.model'; +import { isEmpty } from '../../shared/empty.util'; + +/** + * Component that represents all results for mydspace page + */ +@Component({ + selector: 'ds-my-dspace-results', + templateUrl: './my-dspace-results.component.html', + animations: [ + fadeIn, + fadeInOut + ] +}) +export class MyDSpaceResultsComponent { + + /** + * The actual search result objects + */ + @Input() searchResults: RemoteData>>; + + /** + * The current configuration of the search + */ + @Input() searchConfig: SearchOptions; + + /** + * The current view mode for the search results + */ + @Input() viewMode: ViewMode; + + /** + * A boolean representing if search results entry are separated by a line + */ + hasBorder = true; + + /** + * Check if mydspace search results are loading + */ + isLoading() { + return !this.searchResults || isEmpty(this.searchResults) || this.searchResults.isLoading; + } +} diff --git a/src/app/+my-dspace-page/my-dspace.guard.ts b/src/app/+my-dspace-page/my-dspace.guard.ts new file mode 100644 index 0000000000..9cb9aff485 --- /dev/null +++ b/src/app/+my-dspace-page/my-dspace.guard.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, NavigationExtras, Router, RouterStateSnapshot } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; +import { isEmpty } from '../shared/empty.util'; +import { MYDSPACE_ROUTE } from './my-dspace-page.component'; +import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; +import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; + +/** + * Prevent unauthorized activating and loading of mydspace configuration + * @class MyDSpaceGuard + */ +@Injectable() +export class MyDSpaceGuard implements CanActivate { + + /** + * @constructor + */ + constructor(private configurationService: MyDSpaceConfigurationService, private router: Router) { + } + + /** + * True when configuration is valid + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.configurationService.getAvailableConfigurationTypes().pipe( + first(), + map((configurationList) => this.validateConfigurationParam(route.queryParamMap.get('configuration'), configurationList))); + } + + /** + * Check if the given configuration is present in the list of those available + * + * @param configuration + * the configuration to validate + * @param configurationList + * the list of available configuration + * + */ + private validateConfigurationParam(configuration: string, configurationList: MyDSpaceConfigurationValueType[]): boolean { + const configurationDefault: string = configurationList[0]; + if (isEmpty(configuration) || !configurationList.includes(configuration as MyDSpaceConfigurationValueType)) { + // If configuration param is empty or is not included in available configurations redirect to a default configuration value + const navigationExtras: NavigationExtras = { + queryParams: {configuration: configurationDefault} + }; + + this.router.navigate([MYDSPACE_ROUTE], navigationExtras); + return false; + } else { + return true; + } + } +} diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts index da5920cd6d..d577c2c44c 100644 --- a/src/app/+search-page/filtered-search-page.component.ts +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -2,12 +2,13 @@ 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, Input } from '@angular/core'; +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. @@ -18,7 +19,13 @@ import { PaginatedSearchOptions } from './paginated-search-options.model'; styleUrls: ['./search-page.component.scss'], templateUrl: './search-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [pushInOut] + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] }) export class FilteredSearchPageComponent extends SearchPageComponent { @@ -32,7 +39,7 @@ export class FilteredSearchPageComponent extends SearchPageComponent { constructor(protected service: SearchService, protected sidebarService: SearchSidebarService, protected windowService: HostWindowService, - protected searchConfigService: SearchConfigurationService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, protected routeService: RouteService) { super(service, sidebarService, windowService, searchConfigService, routeService); } 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 387c116a56..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[], fixedFilter?: any, 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-boolean-filter/search-boolean-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss index 030184640e..a28db359b5 100644 --- a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss @@ -2,15 +2,6 @@ @import '../../../../../styles/mixins.scss'; .filters { - a { - color: $body-color; - &:hover, &focus { - text-decoration: none; - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss new file mode 100644 index 0000000000..6452f2469b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss @@ -0,0 +1,11 @@ +@import '../../../../../../styles/variables.scss'; + +a { + color: $body-color; + &:hover, &focus { + text-decoration: none; + } + span.badge { + vertical-align: text-top; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts 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 7a6a51e99d..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,9 +8,11 @@ import { SearchService } from '../../../../search-service/search.service'; import { SearchFilterService } from '../../search-filter.service'; import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; import { hasValue } from '../../../../../shared/empty.util'; +import { FilterType } from '../../../../search-service/filter-type.model'; @Component({ selector: 'ds-search-facet-option', + styleUrls: ['./search-facet-option.component.scss'], templateUrl: './search-facet-option.component.html', }) @@ -31,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 @@ -70,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(); } @@ -84,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.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss new file mode 100644 index 0000000000..7b5448b980 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss @@ -0,0 +1,13 @@ +@import '../../../../../../styles/variables.scss'; + +a { + color: $link-color; + &:hover { + text-decoration: underline; + color: $link-hover-color; + + } + span.badge { + vertical-align: text-top; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts 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 b7f02ad18b..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 @@ -17,6 +17,7 @@ const rangeDelimiter = '-'; @Component({ selector: 'ds-search-facet-range-option', + styleUrls: ['./search-facet-range-option.component.scss'], templateUrl: './search-facet-range-option.component.html', }) @@ -34,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 */ @@ -74,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.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss new file mode 100644 index 0000000000..6452f2469b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss @@ -0,0 +1,11 @@ +@import '../../../../../../styles/variables.scss'; + +a { + color: $body-color; + &:hover, &focus { + text-decoration: none; + } + span.badge { + vertical-align: text-top; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts 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 5137bf8ffc..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,9 +6,12 @@ import { SearchService } from '../../../../search-service/search.service'; import { SearchFilterService } from '../../search-filter.service'; import { hasValue } from '../../../../../shared/empty.util'; import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; @Component({ selector: 'ds-search-facet-selected-option', + styleUrls: ['./search-facet-selected-option.component.scss'], templateUrl: './search-facet-selected-option.component.html', }) @@ -19,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 @@ -29,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 @@ -59,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(); } @@ -69,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.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index dc088dfcc5..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 @@ -21,10 +21,13 @@ 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 @@ -141,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-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-hierarchy-filter/search-hierarchy-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss index 9ec0f61541..d8bb1ff1b3 100644 --- a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss @@ -2,15 +2,6 @@ @import '../../../../../styles/mixins.scss'; .filters { - a { - color: $body-color; - &:hover, &focus { - text-decoration: none; - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html index 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.scss b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss index c45302b162..caaef5985e 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -3,17 +3,6 @@ .filters { - a { - color: $link-color; - &:hover { - text-decoration: underline; - color: $link-hover-color; - - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts index 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-filter/search-text-filter/search-text-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss index 33e354f2d8..d8bb1ff1b3 100644 --- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss @@ -2,15 +2,6 @@ @import '../../../../../styles/mixins.scss'; .filters { - a { - color: $body-color; - &:hover, &focus { - text-decoration: none; - } - span.badge { - vertical-align: text-top; - } - } .toggle-more-filters a { color: $link-color; text-decoration: underline; diff --git a/src/app/+search-page/search-filters/search-filters.component.html b/src/app/+search-page/search-filters/search-filters.component.html index 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 69309bed66..2b18854e1e 100644 --- a/src/app/+search-page/search-options.model.ts +++ b/src/app/+search-page/search-options.model.ts @@ -9,6 +9,7 @@ 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; @@ -16,7 +17,8 @@ export class SearchOptions { filters?: any; fixedFilter?: any; - constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any}) { + 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; @@ -31,6 +33,9 @@ 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); } @@ -45,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.component.html b/src/app/+search-page/search-page.component.html index adab27d8e9..c11e863429 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -2,15 +2,16 @@
+ [resultCount]="(resultsRD$ | async)?.payload.totalElements" [inPlaceSearch]="inPlaceSearch">
+ [scopes]="(scopeListRD$ | async)" + [inPlaceSearch]="inPlaceSearch"> - +
- +
- +
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.scss b/src/app/+search-page/search-sidebar/search-sidebar.component.scss index b5bd6dd30d..35ce5eebce 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.scss +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.scss @@ -8,8 +8,12 @@ ds-view-mode-switch { margin-bottom: $spacer; } - .sidebar-content > *:not(:last-child) { + .sidebar-content > *:not(:last-child):not(ds-search-switch-configuration) { margin-bottom: 4*$spacer; display: block; } + ds-search-switch-configuration { + margin-bottom: 2*$spacer; + display: block; + } } diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.ts b/src/app/+search-page/search-sidebar/search-sidebar.component.ts index 8b68cda793..9ee0a74942 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.ts @@ -1,5 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model'; + /** * This component renders a simple item page. * The route parameter 'id' is used to request the item it represents. @@ -17,13 +19,29 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; */ export class SearchSidebarComponent { + /** + * The list of available configuration options + */ + @Input() configurationList: SearchConfigurationOption[]; + /** * The total amount of results */ @Input() resultCount; + /** + * The list of available view mode options + */ + @Input() viewModeList; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch; + /** * Emits event when the user clicks a button to open or close the sidebar */ @Output() toggleSidebar = new EventEmitter(); + } diff --git a/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts new file mode 100644 index 0000000000..6f9a72da48 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts @@ -0,0 +1,15 @@ +/** + * Represents a search configuration select option + */ +export interface SearchConfigurationOption { + + /** + * The select option value + */ + value: string; + + /** + * The select option label + */ + label: string; +} diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html new file mode 100644 index 0000000000..8df37214d1 --- /dev/null +++ b/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html @@ -0,0 +1,13 @@ +
+
{{ 'search.switch-configuration.title' | translate}}
+ + + +
diff --git a/src/app/+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/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index 895595ba45..aa1f6f2958 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -15,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'; @@ -76,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 edebfa253e..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,6 +254,30 @@ 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, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 20ae0401cc..6550435aa3 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -81,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, @@ -132,6 +138,7 @@ const PROVIDERS = [ RegistryBitstreamformatsResponseParsingService, DebugResponseParsingService, SearchResponseParsingService, + MyDSpaceResponseParsingService, ServerResponseService, BrowseResponseParsingService, BrowseEntriesResponseParsingService, @@ -163,6 +170,11 @@ const PROVIDERS = [ MenuService, ObjectUpdatesService, SearchService, + MyDSpaceGuard, + RoleService, + TaskResponseParsingService, + ClaimedTaskDataService, + PoolTaskDataService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 71564883f2..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 @@ -144,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/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.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts index 79b1b2df72..3f798e61a2 100644 --- a/src/app/core/data/object-updates/object-updates.effects.spec.ts +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -24,6 +24,7 @@ describe('ObjectUpdatesEffects', () => { let actions: Observable; let testURL = 'www.dspace.org/dspace7'; let testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + const fakeID = 'id'; beforeEach(async(() => { TestBed.configureTestingModule({ providers: [ @@ -44,7 +45,9 @@ describe('ObjectUpdatesEffects', () => { testURL = 'www.dspace.org/dspace7'; testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; updatesEffects = TestBed.get(ObjectUpdatesEffects); - (updatesEffects as any).actionMap[testURL] = new Subject(); + (updatesEffects as any).actionMap$[testURL] = new Subject(); + (updatesEffects as any).notificationActionMap$[fakeID] = new Subject(); + (updatesEffects as any).notificationActionMap$[(updatesEffects as any).allIdentifier] = new Subject(); }); describe('mapLastActions$', () => { @@ -57,7 +60,7 @@ describe('ObjectUpdatesEffects', () => { it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { action = new RemoveObjectUpdatesAction(testURL); actions = hot('--a-', { a: action }); - (updatesEffects as any).actionMap[testURL].subscribe((act) => emittedAction = act); + (updatesEffects as any).actionMap$[testURL].subscribe((act) => emittedAction = act); const expected = cold('--b-', { b: undefined }); expect(updatesEffects.mapLastActions$).toBeObservable(expected); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index ae49071dc1..88cd3bc718 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -6,21 +6,32 @@ import { ObjectUpdatesActionTypes, RemoveObjectUpdatesAction } from './object-updates.actions'; -import { delay, map, switchMap, take, tap } from 'rxjs/operators'; +import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { of as observableOf, race as observableRace, Subject } from 'rxjs'; import { hasNoValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { INotification } from '../../../shared/notifications/models/notification.model'; +import { + NotificationsActions, + NotificationsActionTypes, + RemoveNotificationAction +} from '../../../shared/notifications/notifications.actions'; /** * NGRX effects for ObjectUpdatesActions */ @Injectable() export class ObjectUpdatesEffects { + + /** + * Identifier for when an action on all notifications is performed + */ + private allIdentifier = 'all'; + /** * Map that keeps track of the latest ObjectUpdatesAction for each page's url */ - private actionMap: { + private actionMap$: { /* Use Subject instead of BehaviorSubject: we only want Actions that are fired while we're listening actions that were previously fired do not matter anymore @@ -28,18 +39,41 @@ export class ObjectUpdatesEffects { [url: string]: Subject } = {}; + private notificationActionMap$: { + /* Use Subject instead of BehaviorSubject: + we only want Actions that are fired while we're listening + actions that were previously fired do not matter anymore + */ + [id: string]: Subject + } = { all: new Subject() }; /** * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key */ @Effect({ dispatch: false }) mapLastActions$ = this.actions$ .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), - map((action: DiscardObjectUpdatesAction) => { + map((action: ObjectUpdatesAction) => { const url: string = action.payload.url; - if (hasNoValue(this.actionMap[url])) { - this.actionMap[url] = new Subject(); + if (hasNoValue(this.actionMap$[url])) { + this.actionMap$[url] = new Subject(); } - this.actionMap[url].next(action); + this.actionMap$[url].next(action); + } + ) + ); + + /** + * Effect that makes sure all last fired NotificationActions are stored in the notification map of this service, with the id as their key + */ + @Effect({ dispatch: false }) mapLastNotificationActions$ = this.actions$ + .pipe( + ofType(...Object.values(NotificationsActionTypes)), + map((action: RemoveNotificationAction) => { + const id: string = action.payload.id || action.payload || this.allIdentifier; + if (hasNoValue(this.notificationActionMap$[id])) { + this.notificationActionMap$[id] = new Subject(); + } + this.notificationActionMap$[id].next(action); } ) ); @@ -61,17 +95,30 @@ export class ObjectUpdatesEffects { // Either wait for the delay and perform a remove action observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)), // Or wait for a a user action - this.actionMap[url].pipe( + this.actionMap$[url].pipe( take(1), - tap(() => this.notificationsService.remove(notification)), + tap(() => { + this.notificationsService.remove(notification); + }), map((updateAction: ObjectUpdatesAction) => { if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { // If someone reinstated, do nothing, just let the reinstating happen return { type: 'NO_ACTION' } - } else { - // If someone performed another action, assume the user does not want to reinstate and remove all changes - return new RemoveObjectUpdatesAction(action.payload.url); } + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return new RemoveObjectUpdatesAction(action.payload.url); + }) + ), + this.notificationActionMap$[notification.id].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), + map(() => { + return new RemoveObjectUpdatesAction(action.payload.url); + }) + ), + this.notificationActionMap$[this.allIdentifier].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), + map(() => { + return new RemoveObjectUpdatesAction(action.payload.url); }) ) ) @@ -81,7 +128,6 @@ export class ObjectUpdatesEffects { constructor(private actions$: Actions, private notificationsService: NotificationsService) { - } } 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 0e9d39849b..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) => { @@ -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/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 8fa1ca893a..137b4c3a87 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -196,7 +196,8 @@ export class RegistryService { const metadatafieldsObs: Observable = rmrObs.pipe( map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields), - map((metadataFields: MetadataField[]) => metadataFields) + /* Make sure to explicitly cast this into a MetadataField object, on first page loads this object comes from the object cache created by the server and its prototype is unknown */ + map((metadataFields: MetadataField[]) => metadataFields.map((metadataField: MetadataField) => Object.assign(new MetadataField(), metadataField))) ); const pageInfoObs: Observable = requestEntryObs.pipe( 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/item.model.ts b/src/app/core/shared/item.model.ts index 645b50d5db..839103b9f5 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -5,7 +5,7 @@ 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'; @@ -95,7 +95,7 @@ export class Item extends DSpaceObject { */ 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/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 d5afa4105f..bdcc3a52f6 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -23,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 { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; + +} diff --git a/src/app/core/tasks/models/normalized-pool-task-object.model.ts b/src/app/core/tasks/models/normalized-pool-task-object.model.ts new file mode 100644 index 0000000000..22cda6ff9c --- /dev/null +++ b/src/app/core/tasks/models/normalized-pool-task-object.model.ts @@ -0,0 +1,38 @@ +import { NormalizedTaskObject } from './normalized-task-object.model'; +import { PoolTask } from './pool-task-object.model'; +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * A normalized model class for a PoolTask. + */ +@mapsTo(PoolTask) +@inheritSerialization(NormalizedTaskObject) +export class NormalizedPoolTask extends NormalizedTaskObject { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; +} diff --git a/src/app/core/tasks/models/normalized-task-object.model.ts b/src/app/core/tasks/models/normalized-task-object.model.ts new file mode 100644 index 0000000000..52c274e3a8 --- /dev/null +++ b/src/app/core/tasks/models/normalized-task-object.model.ts @@ -0,0 +1,39 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { TaskObject } from './task-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * An abstract normalized model class for a TaskObject. + */ +@mapsTo(TaskObject) +@inheritSerialization(NormalizedDSpaceObject) +export abstract class NormalizedTaskObject extends NormalizedDSpaceObject { + + /** + * The task identifier + */ + @autoserialize + id: string; + + /** + * The workflow step + */ + @autoserialize + step: string; + + /** + * The task action type + */ + @autoserialize + action: string; + + /** + * The workflowitem object whom this task is related + */ + @autoserialize + @relationship(ResourceType.Workflowitem, false) + workflowitem: string; +} diff --git a/src/app/core/tasks/models/pool-task-object.model.ts b/src/app/core/tasks/models/pool-task-object.model.ts new file mode 100644 index 0000000000..8d98d3e1a5 --- /dev/null +++ b/src/app/core/tasks/models/pool-task-object.model.ts @@ -0,0 +1,8 @@ +import { TaskObject } from './task-object.model'; + +/** + * A model class for a PoolTask. + */ +export class PoolTask extends TaskObject { + +} diff --git a/src/app/core/tasks/models/process-task-response.ts b/src/app/core/tasks/models/process-task-response.ts new file mode 100644 index 0000000000..ca4bc9a068 --- /dev/null +++ b/src/app/core/tasks/models/process-task-response.ts @@ -0,0 +1,17 @@ +import { RemoteDataError } from '../../data/remote-data-error'; + +/** + * A class to represent the data retrieved by after processing a task + */ +export class ProcessTaskResponse { + constructor( + private isSuccessful: boolean, + public error?: RemoteDataError, + public payload?: any + ) { + } + + get hasSucceeded(): boolean { + return this.isSuccessful; + } +} diff --git a/src/app/core/tasks/models/task-object.model.ts b/src/app/core/tasks/models/task-object.model.ts new file mode 100644 index 0000000000..97a1c9f59e --- /dev/null +++ b/src/app/core/tasks/models/task-object.model.ts @@ -0,0 +1,33 @@ +import { Observable } from 'rxjs'; + +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { RemoteData } from '../../data/remote-data'; +import { Workflowitem } from '../../submission/models/workflowitem.model'; + +/** + * An abstract model class for a TaskObject. + */ +export class TaskObject extends DSpaceObject implements CacheableObject, ListableObject { + + /** + * The task identifier + */ + id: string; + + /** + * The workflow step + */ + step: string; + + /** + * The task action type + */ + action: string; + + /** + * The workflowitem object whom this task is related + */ + workflowitem: Observable> | Workflowitem; +} diff --git a/src/app/core/tasks/pool-task-data.service.spec.ts b/src/app/core/tasks/pool-task-data.service.spec.ts new file mode 100644 index 0000000000..7f40c6e89c --- /dev/null +++ b/src/app/core/tasks/pool-task-data.service.spec.ts @@ -0,0 +1,70 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { CoreState } from '../core.reducers'; +import { PoolTaskDataService } from './pool-task-data.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +describe('PoolTaskDataService', () => { + let service: PoolTaskDataService; + let options: HttpOptions; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'pooltasks'; + const requestService = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store; + + function initTestService(): PoolTaskDataService { + return new PoolTaskDataService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + service = initTestService(); + options = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + }); + + describe('claimTask', () => { + + it('should call postToEndpoint method', () => { + spyOn(service, 'postToEndpoint'); + const scopeId = '1234'; + service.claimTask(scopeId); + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, {}, scopeId, options); + }); + }); +}); diff --git a/src/app/core/tasks/pool-task-data.service.ts b/src/app/core/tasks/pool-task-data.service.ts new file mode 100644 index 0000000000..1a93450d4d --- /dev/null +++ b/src/app/core/tasks/pool-task-data.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Observable } from 'rxjs'; +import { Store } from '@ngrx/store'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { RequestService } from '../data/request.service'; +import { PoolTask } from './models/pool-task-object.model'; +import { TasksService } from './tasks.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ProcessTaskResponse } from './models/process-task-response'; + +/** + * The service handling all REST requests for PoolTask + */ +@Injectable() +export class PoolTaskDataService extends TasksService { + + /** + * The endpoint link name + */ + protected linkPath = 'pooltasks'; + + /** + * When true, a new request is always dispatched + */ + protected forceBypassCache = true; + + /** + * Initialize instance variables + * + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {NormalizedObjectBuildService} dataBuildService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DSOChangeAnalyzer, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } + + /** + * Make a request to claim the given task + * + * @param scopeId + * The task id + * @return {Observable} + * Emit the server response + */ + public claimTask(scopeId: string): Observable { + return this.postToEndpoint(this.linkPath, {}, scopeId, this.makeHttpOptions()); + } +} diff --git a/src/app/core/tasks/task-response-parsing.service.ts b/src/app/core/tasks/task-response-parsing.service.ts new file mode 100644 index 0000000000..7445f9d267 --- /dev/null +++ b/src/app/core/tasks/task-response-parsing.service.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@angular/core'; + +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; + +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { ErrorResponse, RestResponse, TaskResponse } from '../cache/response.models'; + +/** + * Provides methods to parse response for a task request. + */ +@Injectable() +export class TaskResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = false; + + /** + * Initialize instance variables + * + * @param {GlobalConfig} EnvConfig + * @param {ObjectCacheService} objectCache + */ + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService,) { + super(); + } + + /** + * Parses data from the tasks endpoints + * + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (this.isSuccessStatus(data.statusCode)) { + return new TaskResponse( data.statusCode, data.statusText); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from server'), + { statusCode: data.statusCode, statusText: data.statusText } + ) + ); + } + } + +} diff --git a/src/app/core/tasks/tasks.service.spec.ts b/src/app/core/tasks/tasks.service.spec.ts new file mode 100644 index 0000000000..753ce2ddd5 --- /dev/null +++ b/src/app/core/tasks/tasks.service.spec.ts @@ -0,0 +1,130 @@ +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { TasksService } from './tasks.service'; +import { RequestService } from '../data/request.service'; +import { TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { TaskObject } from './models/task-object.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { compare, Operation } from 'fast-json-patch'; +import { NormalizedTaskObject } from './models/normalized-task-object.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +const LINK_NAME = 'test'; + +/* tslint:disable:max-classes-per-file */ +class TestTask extends TaskObject { +} + +class TestService extends TasksService { + protected linkPath = LINK_NAME; + protected forceBypassCache = true; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } +} + +class NormalizedTestTaskObject extends NormalizedTaskObject { +} + +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: NormalizedTestTaskObject, object2: NormalizedTestTaskObject): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } + +} +/* tslint:enable:max-classes-per-file */ + +describe('TasksService', () => { + let scheduler: TestScheduler; + let service: TestService; + const taskEndpoint = 'https://rest.api/task'; + const linkPath = 'testTask'; + const requestService = getMockRequestService(); + const halService: any = new HALEndpointServiceStub(taskEndpoint); + const rdbService = {} as RemoteDataBuildService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = new DummyChangeAnalyzer() as any; + const dataBuildService = { + normalize: (object) => object + } as NormalizedObjectBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + } + } as any; + const store = {} as Store; + + function initTestService(): TestService { + return new TestService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + + }); + + describe('postToEndpoint', () => { + + it('should configure a new TaskPostRequest', () => { + const expected = new TaskPostRequest(requestService.generateRequestId(), `${taskEndpoint}/${linkPath}`, {}); + scheduler.schedule(() => service.postToEndpoint('testTask', {}).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('deleteById', () => { + + it('should configure a new TaskDeleteRequest', () => { + const scopeId = '1234'; + const expected = new TaskDeleteRequest(requestService.generateRequestId(), `${taskEndpoint}/${linkPath}/${scopeId}`, null); + scheduler.schedule(() => service.deleteById('testTask', scopeId).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + +}); diff --git a/src/app/core/tasks/tasks.service.ts b/src/app/core/tasks/tasks.service.ts new file mode 100644 index 0000000000..f39b144c6a --- /dev/null +++ b/src/app/core/tasks/tasks.service.ts @@ -0,0 +1,125 @@ +import { HttpHeaders } from '@angular/common/http'; + +import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map, mergeMap, tap } from 'rxjs/operators'; + +import { DataService } from '../data/data.service'; +import { DeleteRequest, FindAllOptions, PostRequest, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { ProcessTaskResponse } from './models/process-task-response'; +import { RemoteDataError } from '../data/remote-data-error'; +import { getResponseFromEntry } from '../shared/operators'; +import { ErrorResponse, MessageResponse, RestResponse } from '../cache/response.models'; +import { CacheableObject } from '../cache/object-cache.reducer'; + +/** + * An abstract class that provides methods to handle task requests. + */ +export abstract class TasksService extends DataService { + + public getBrowseEndpoint(options: FindAllOptions): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Fetch a RestRequest + * + * @param requestId + * The base endpoint for the type of object + * @return Observable + * server response + */ + protected fetchRequest(requestId: string): Observable { + const responses = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + const errorResponses = responses.pipe( + filter((response: RestResponse) => !response.isSuccessful), + mergeMap((response: ErrorResponse) => observableOf( + new ProcessTaskResponse( + response.isSuccessful, + new RemoteDataError(response.statusCode, response.statusText, response.errorMessage) + )) + )); + const successResponses = responses.pipe( + filter((response: RestResponse) => response.isSuccessful), + map((response: MessageResponse) => new ProcessTaskResponse(response.isSuccessful)), + distinctUntilChanged() + ); + return observableMerge(errorResponses, successResponses); + } + + /** + * Create the HREF for a specific submission object based on its identifier + * + * @param endpoint + * The base endpoint for the type of object + * @param resourceID + * The identifier for the object + */ + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + /** + * Make a new post request + * + * @param linkPath + * The endpoint link name + * @param body + * The request body + * @param scopeId + * The task id to be removed + * @param options + * The HttpOptions object + * @return Observable + * server response + */ + public postToEndpoint(linkPath: string, body: any, scopeId?: string, options?: HttpOptions): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + distinctUntilChanged(), + map((endpointURL: string) => new TaskPostRequest(requestId, endpointURL, body, options)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap((request: PostRequest) => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Delete an existing task on the server + * + * @param linkPath + * The endpoint link name + * @param scopeId + * The task id to be removed + * @param options + * The HttpOptions object + * @return Observable + * server response + */ + public deleteById(linkPath: string, scopeId: string, options?: HttpOptions): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkPath || this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + map((endpointURL: string) => new TaskDeleteRequest(requestId, endpointURL, null, options)), + tap((request: DeleteRequest) => this.requestService.configure(request)), + flatMap((request: DeleteRequest) => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Create a new HttpOptions + */ + protected makeHttpOptions() { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + options.headers = headers; + return options; + } +} diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index d1f8feeebc..08e386182b 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -10,10 +10,10 @@ [fields]="['project.identifier.status']" [label]="'project.page.status'"> - - + + diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts index 6a73e187c9..46bc7c9f88 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; 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 { isNotEmpty } from '../../../../shared/empty.util'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; @@ -19,6 +20,11 @@ import { * 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 */ @@ -38,6 +44,8 @@ export class ProjectComponent extends ItemComponent implements OnInit { super.ngOnInit(); if (isNotEmpty(this.resolvedRelsAndTypes$)) { + this.contributors$ = this.buildRepresentations('OrgUnit', 'project.contributor.other'); + this.people$ = this.resolvedRelsAndTypes$.pipe( filterRelationsByTypeLabel('isPersonOfProject'), relationsToItems(this.item.id) diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 402eb7a44d..a03fd01c53 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -5,7 +5,7 @@
diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index 04111a4ea6..5ec553222b 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -1,4 +1,4 @@ -import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { DSOSelectorComponent } from './dso-selector.component'; @@ -27,7 +27,7 @@ describe('DSOSelectorComponent', () => { language: undefined })] }; - searchResult.dspaceObject = item; + searchResult.indexableObject = item; searchResult.hitHighlights = {}; const searchService = jasmine.createSpyObj('searchService', { search: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(undefined, [searchResult]))) diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts index 1e129c0dbe..0533addb01 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -1,7 +1,5 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; -import { Community } from '../../../../core/shared/community.model'; -import { RemoteData } from '../../../../core/data/remote-data'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -9,10 +7,7 @@ import { COLLECTION_PARENT_PARAMETER, getCollectionCreatePath } from '../../../../+collection-page/collection-page-routing.module'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from '../dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; /** * Component to wrap a list of existing communities inside a modal diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index dac5888bf7..29af9f624e 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -1,18 +1,9 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Community } from '../../../../core/shared/community.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { Collection } from '../../../../core/shared/collection.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; -import { hasValue } from '../../../empty.util'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from '../dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; /** * Component to wrap a list of existing collections inside a modal diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index ea857f7d62..4ceaeccb3a 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -5,10 +5,7 @@ import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model' import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; import { of as observableOf } from 'rxjs'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from './dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from './dso-selector-modal-wrapper.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ActivatedRoute } from '@angular/router'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; @@ -16,7 +13,7 @@ import { first } from 'rxjs/operators'; import { By } from '@angular/platform-browser'; import { DSOSelectorComponent } from '../dso-selector/dso-selector.component'; import { MockComponent } from 'ng-mocks'; -import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models'; +import { MetadataValue } from '../../../core/shared/metadata.models'; describe('DSOSelectorModalWrapperComponent', () => { let component: DSOSelectorModalWrapperComponent; diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index 351a92302c..881476cac6 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -1,4 +1,4 @@ -import { Component, Injectable, Input, OnInit } from '@angular/core'; +import { Injectable, Input, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts index 9182df8045..dae36d3017 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -1,19 +1,10 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; -import { Community } from '../../../../core/shared/community.model'; -import { RemoteData } from '../../../../core/data/remote-data'; +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { Collection } from '../../../../core/shared/collection.model'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Item } from '../../../../core/shared/item.model'; import { getItemEditPath } from '../../../../+item-page/item-page-routing.module'; -import { - DSOSelectorModalWrapperComponent, - SelectorActionType -} from '../dso-selector-modal-wrapper.component'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; /** * Component to wrap a list of existing items inside a modal diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index d61134347a..a44a20d4bd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -168,7 +168,7 @@ export class DsDynamicTagComponent extends DynamicFormControlComponent implement } private addTagsToChips() { - if (!this.hasAuthority || !this.model.authorityOptions.closed) { + if (hasValue(this.currentValue) && (!this.hasAuthority || !this.model.authorityOptions.closed)) { let res: string[] = []; res = this.currentValue.split(','); diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 727421c83e..9f59f42cc4 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -12,7 +12,7 @@ import { ViewChildren } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { hasValue, isNotEmpty, isNotUndefined } from '../empty.util'; +import { hasValue, isNotEmpty } from '../empty.util'; import { InputSuggestion } from './input-suggestions.model'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; diff --git a/src/app/shared/items/switcher/item-type-switcher.component.ts b/src/app/shared/items/switcher/item-type-switcher.component.ts index 3a3f193114..21a045b8f4 100644 --- a/src/app/shared/items/switcher/item-type-switcher.component.ts +++ b/src/app/shared/items/switcher/item-type-switcher.component.ts @@ -54,9 +54,9 @@ export class ItemTypeSwitcherComponent implements OnInit { } let item: Item; - if (hasValue((this.object as any).dspaceObject)) { + if (hasValue((this.object as any).indexableObject)) { const searchResult = this.object as ItemSearchResult; - item = searchResult.dspaceObject; + item = searchResult.indexableObject; } else { item = this.object as Item; } diff --git a/src/app/shared/log-out/log-out.component.html b/src/app/shared/log-out/log-out.component.html index f3ceae0087..d522fc6fb9 100644 --- a/src/app/shared/log-out/log-out.component.html +++ b/src/app/shared/log-out/log-out.component.html @@ -1,7 +1,6 @@ - -