diff --git a/.travis.yml b/.travis.yml index 403a10b770..ee3604ffc3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,37 @@ sudo: required dist: trusty + +env: + # Install the latest docker-compose version for ci testing. + # The default installation in travis is not compatible with the latest docker-compose file version. + COMPOSE_VERSION: 1.24.1 + # The ci step will test the dspace-angular code against DSpace REST. + # Direct that step to utilize a DSpace REST service that has been started in docker. + DSPACE_REST_HOST: localhost + DSPACE_REST_PORT: 8080 + DSPACE_REST_NAMESPACE: '/server/api' + DSPACE_REST_SSL: false + +before_install: + # Docker Compose Install + - curl -L https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose + - chmod +x docker-compose + - sudo mv docker-compose /usr/local/bin + - git clone https://github.com/DSpace-Labs/DSpace-Docker-Images.git + +install: + - docker-compose version + - docker-compose -f DSpace-Docker-Images/docker-compose-files/dspace-compose/d7.travis.yml up -d + - travis_retry yarn install + +before_script: + # The following line could be enabled to verify that the rest server is responding. + # Currently, "yarn run build" takes enough time to run to allow the service to be available + #- curl http://localhost:8080/ + +after_script: + - docker-compose -f DSpace-Docker-Images/docker-compose-files/dspace-compose/d7.travis.yml down + addons: apt: sources: @@ -18,9 +50,6 @@ cache: bundler_args: --retry 5 -install: - - travis_retry yarn install - script: # Use Chromium instead of Chrome. - export CHROME_BIN=chromium-browser diff --git a/config/environment.test.js b/config/environment.test.js index f4d625303f..6897e29aa7 100644 --- a/config/environment.test.js +++ b/config/environment.test.js @@ -1,3 +1,4 @@ +// This configuration is currently only being used for unit tests, end-to-end tests use environment.dev.ts module.exports = { }; diff --git a/package.json b/package.json index 7916379039..327f68c5d2 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,10 @@ "clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld", "clean": "yarn run clean:prod && yarn run clean:node", "prebuild": "yarn run clean:bld && yarn run clean:dist", - "prebuild:aot": "yarn run prebuild", + "prebuild:ci": "yarn run prebuild", "prebuild:prod": "yarn run prebuild", "build": "node ./scripts/webpack.js --progress --mode development", - "build:aot": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode development && node ./scripts/webpack.js --env.aot --env.client --mode development", + "build:ci": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode development && node ./scripts/webpack.js --env.aot --env.client --mode development", "build:prod": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode production && node ./scripts/webpack.js --env.aot --env.client --mode production", "postbuild:prod": "yarn run rollup", "rollup": "rollup -c rollup.config.js", @@ -51,10 +51,13 @@ "debug:server": "node-nightly --inspect --debug-brk dist/server.js", "debug:build": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --mode development", "debug:build:prod": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --env.aot --env.client --env.server --mode production", - "ci": "yarn run lint && yarn run build:aot && yarn run test:headless", + "ci": "yarn run lint && yarn run build:ci && yarn run test:headless && npm-run-all -p -r server e2e", "protractor": "node node_modules/protractor/bin/protractor", "pree2e": "yarn run webdriver:update", "e2e": "yarn run protractor", + "pretest": "yarn run clean:bld", + "pretest:headless": "yarn run pretest", + "pretest:watch": "yarn run pretest", "test": "karma start --single-run", "test:headless": "karma start --single-run --browsers ChromeHeadless", "test:watch": "karma start --no-single-run --auto-watch", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 63bc598304..50f67c6cb8 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -2,16 +2,47 @@ "404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ", "404.link.home-page": "Take me to the home page", "404.page-not-found": "page not found", + "admin.registries.bitstream-formats.create.failure.content": "An error occurred while creating the new bitstream format.", + "admin.registries.bitstream-formats.create.failure.head": "Failure", + "admin.registries.bitstream-formats.create.head": "Create Bitstream format", + "admin.registries.bitstream-formats.create.new": "Add a new bitstream format", + "admin.registries.bitstream-formats.create.success.content": "The new bitstream format was successfully created.", + "admin.registries.bitstream-formats.create.success.head": "Success", + "admin.registries.bitstream-formats.delete.failure.amount": "Failed to remove {{ amount }} format(s)", + "admin.registries.bitstream-formats.delete.failure.head": "Failure", + "admin.registries.bitstream-formats.delete.success.amount": "Successfully removed {{ amount }} format(s)", + "admin.registries.bitstream-formats.delete.success.head": "Success", "admin.registries.bitstream-formats.description": "This list of bitstream formats provides information about known formats and their support level.", - "admin.registries.bitstream-formats.formats.no-items": "No bitstream formats to show.", - "admin.registries.bitstream-formats.formats.table.internal": "internal", - "admin.registries.bitstream-formats.formats.table.mimetype": "MIME Type", - "admin.registries.bitstream-formats.formats.table.name": "Name", - "admin.registries.bitstream-formats.formats.table.supportLevel.0": "Unknown", - "admin.registries.bitstream-formats.formats.table.supportLevel.1": "Known", - "admin.registries.bitstream-formats.formats.table.supportLevel.2": "Support", - "admin.registries.bitstream-formats.formats.table.supportLevel.head": "Support Level", + "admin.registries.bitstream-formats.edit.description.hint": "", + "admin.registries.bitstream-formats.edit.description.label": "Description", + "admin.registries.bitstream-formats.edit.extensions.hint": "Extensions are file extensions that are used to automatically identify the format of uploaded files. You can enter several extensions for each format.", + "admin.registries.bitstream-formats.edit.extensions.label": "File extensions", + "admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extenstion without the dot", + "admin.registries.bitstream-formats.edit.failure.content": "An error occurred while editing the bitstream format.", + "admin.registries.bitstream-formats.edit.failure.head": "Failure", + "admin.registries.bitstream-formats.edit.head": "Bitstream format: {{ format }}", + "admin.registries.bitstream-formats.edit.internal.hint": "Formats marked as internal are are hidden from the user, and used for administrative purposes.", + "admin.registries.bitstream-formats.edit.internal.label": "Internal", + "admin.registries.bitstream-formats.edit.mimetype.hint": "The MIME type associated with this format, does not have to be unique.", + "admin.registries.bitstream-formats.edit.mimetype.label": "MIME Type", + "admin.registries.bitstream-formats.edit.shortDescription.hint": "A unique name for this format, (e.g. Microsoft Word XP or Microsoft Word 2000)", + "admin.registries.bitstream-formats.edit.shortDescription.label": "Name", + "admin.registries.bitstream-formats.edit.success.content": "The bitstream format was successfully edited.", + "admin.registries.bitstream-formats.edit.success.head": "Success", + "admin.registries.bitstream-formats.edit.supportLevel.hint": "The level of support your institution pledges for this format.", + "admin.registries.bitstream-formats.edit.supportLevel.label": "Support level", "admin.registries.bitstream-formats.head": "Bitstream Format Registry", + "admin.registries.bitstream-formats.no-items": "No bitstream formats to show.", + "admin.registries.bitstream-formats.table.delete": "Delete selected", + "admin.registries.bitstream-formats.table.deselect-all": "Deselect all", + "admin.registries.bitstream-formats.table.internal": "internal", + "admin.registries.bitstream-formats.table.mimetype": "MIME Type", + "admin.registries.bitstream-formats.table.name": "Name", + "admin.registries.bitstream-formats.table.return": "Return", + "admin.registries.bitstream-formats.table.supportLevel.KNOWN": "Known", + "admin.registries.bitstream-formats.table.supportLevel.SUPPORTED": "Supported", + "admin.registries.bitstream-formats.table.supportLevel.UNKNOWN": "Unknown", + "admin.registries.bitstream-formats.table.supportLevel.head": "Support Level", "admin.registries.bitstream-formats.title": "DSpace Angular :: Bitstream Format Registry", "admin.registries.metadata.description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.", "admin.registries.metadata.form.create": "Create metadata schema", @@ -101,6 +132,7 @@ "collection.form.tableofcontents": "News (HTML)", "collection.form.title": "Name", "collection.page.browse.recent.head": "Recent Submissions", + "collection.page.browse.recent.empty": "No items to show", "collection.page.license": "License", "collection.page.news": "News", "community.create.head": "Create a Community", @@ -224,12 +256,26 @@ "item.edit.reinstate.error": "An error occurred while reinstating the item", "item.edit.reinstate.header": "Reinstate item: {{ id }}", "item.edit.reinstate.success": "The item was reinstated successfully", + "item.edit.relationships.discard-button": "Discard", + "item.edit.relationships.edit.buttons.remove": "Remove", + "item.edit.relationships.edit.buttons.undo": "Undo changes", + "item.edit.relationships.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + "item.edit.relationships.notifications.discarded.title": "Changes discarded", + "item.edit.relationships.notifications.failed.title": "Error deleting relationship", + "item.edit.relationships.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", + "item.edit.relationships.notifications.outdated.title": "Changes outdated", + "item.edit.relationships.notifications.saved.content": "Your changes to this item's relationships were saved.", + "item.edit.relationships.notifications.saved.title": "Relationships saved", + "item.edit.relationships.reinstate-button": "Undo", + "item.edit.relationships.save-button": "Save", "item.edit.tabs.bitstreams.head": "Item Bitstreams", "item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams", "item.edit.tabs.curate.head": "Curate", "item.edit.tabs.curate.title": "Item Edit - Curate", "item.edit.tabs.metadata.head": "Item Metadata", "item.edit.tabs.metadata.title": "Item Edit - Metadata", + "item.edit.tabs.relationships.head": "Item Relationships", + "item.edit.tabs.relationships.title": "Item Edit - Relationships", "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", "item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies", "item.edit.tabs.status.buttons.delete.button": "Permanently delete", @@ -659,4 +705,4 @@ "uploader.or": ", or", "uploader.processing": "Processing", "uploader.queue-lenght": "Queue length" -} +} \ No newline at end of file diff --git a/src/app/+admin/admin-registries/admin-registries-routing.module.ts b/src/app/+admin/admin-registries/admin-registries-routing.module.ts index 8e3c322bc8..afdc46bf17 100644 --- a/src/app/+admin/admin-registries/admin-registries-routing.module.ts +++ b/src/app/+admin/admin-registries/admin-registries-routing.module.ts @@ -2,14 +2,29 @@ import { MetadataRegistryComponent } from './metadata-registry/metadata-registry import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component'; -import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component'; +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getRegistriesModulePath } from '../admin-routing.module'; + +const BITSTREAMFORMATS_MODULE_PATH = 'bitstream-formats'; + +export function getBitstreamFormatsModulePath() { + return new URLCombiner(getRegistriesModulePath(), BITSTREAMFORMATS_MODULE_PATH).toString(); +} @NgModule({ imports: [ RouterModule.forChild([ - { path: 'metadata', component: MetadataRegistryComponent, data: { title: 'admin.registries.metadata.title' } }, - { path: 'metadata/:schemaName', component: MetadataSchemaComponent, data: { title: 'admin.registries.schema.title' } }, - { path: 'bitstream-formats', component: BitstreamFormatsComponent, data: { title: 'admin.registries.bitstream-formats.title' } }, + {path: 'metadata', component: MetadataRegistryComponent, data: {title: 'admin.registries.metadata.title'}}, + { + path: 'metadata/:schemaName', + component: MetadataSchemaComponent, + data: {title: 'admin.registries.schema.title'} + }, + { + path: BITSTREAMFORMATS_MODULE_PATH, + loadChildren: './bitstream-formats/bitstream-formats.module#BitstreamFormatsModule', + data: {title: 'admin.registries.bitstream-formats.title'} + }, ]) ] }) diff --git a/src/app/+admin/admin-registries/admin-registries.module.ts b/src/app/+admin/admin-registries/admin-registries.module.ts index c7890e6697..bbeb59f0ab 100644 --- a/src/app/+admin/admin-registries/admin-registries.module.ts +++ b/src/app/+admin/admin-registries/admin-registries.module.ts @@ -5,10 +5,10 @@ import { CommonModule } from '@angular/common'; import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component'; import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component'; import { SharedModule } from '../../shared/shared.module'; import { MetadataSchemaFormComponent } from './metadata-registry/metadata-schema-form/metadata-schema-form.component'; -import {MetadataFieldFormComponent} from './metadata-schema/metadata-field-form/metadata-field-form.component'; +import { MetadataFieldFormComponent } from './metadata-schema/metadata-field-form/metadata-field-form.component'; +import { BitstreamFormatsModule } from './bitstream-formats/bitstream-formats.module'; @NgModule({ imports: [ @@ -16,12 +16,12 @@ import {MetadataFieldFormComponent} from './metadata-schema/metadata-field-form/ SharedModule, RouterModule, TranslateModule, + BitstreamFormatsModule, AdminRegistriesRoutingModule ], declarations: [ MetadataRegistryComponent, MetadataSchemaComponent, - BitstreamFormatsComponent, MetadataSchemaFormComponent, MetadataFieldFormComponent ], diff --git a/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.html b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.html new file mode 100644 index 0000000000..2b65b369b2 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.html @@ -0,0 +1,11 @@ +
+
+
+

{{ 'admin.registries.bitstream-formats.create.new' | translate }}

+ + + +
+
+
\ No newline at end of file diff --git a/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.spec.ts new file mode 100644 index 0000000000..0a10633956 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.spec.ts @@ -0,0 +1,106 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { Router } from '@angular/router'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { RouterStub } from '../../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service-stub'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; +import { RestResponse } from '../../../../core/cache/response.models'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level'; +import { ResourceType } from '../../../../core/shared/resource-type'; +import { AddBitstreamFormatComponent } from './add-bitstream-format.component'; + +describe('AddBitstreamFormatComponent', () => { + let comp: AddBitstreamFormatComponent; + let fixture: ComponentFixture; + + const bitstreamFormat = new BitstreamFormat(); + bitstreamFormat.uuid = 'test-uuid-1'; + bitstreamFormat.id = 'test-uuid-1'; + bitstreamFormat.shortDescription = 'Unknown'; + bitstreamFormat.description = 'Unknown data format'; + bitstreamFormat.mimetype = 'application/octet-stream'; + bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat.internal = false; + bitstreamFormat.extensions = null; + + let router; + let notificationService: NotificationsServiceStub; + let bitstreamFormatDataService: BitstreamFormatDataService; + + const initAsync = () => { + router = new RouterStub(); + notificationService = new NotificationsServiceStub(); + bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', { + createBitstreamFormat: observableOf(new RestResponse(true, 200, 'Success')), + clearBitStreamFormatRequests: observableOf(null) + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [AddBitstreamFormatComponent], + providers: [ + {provide: Router, useValue: router}, + {provide: NotificationsService, useValue: notificationService}, + {provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + }; + + const initBeforeEach = () => { + fixture = TestBed.createComponent(AddBitstreamFormatComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }; + + describe('createBitstreamFormat success', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + it('should send the updated form to the service, show a notification and navigate to ', () => { + comp.createBitstreamFormat(bitstreamFormat); + + expect(bitstreamFormatDataService.createBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat); + expect(notificationService.success).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/admin/registries/bitstream-formats']); + + }); + }); + describe('createBitstreamFormat error', () => { + beforeEach(async(() => { + router = new RouterStub(); + notificationService = new NotificationsServiceStub(); + bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', { + createBitstreamFormat: observableOf(new RestResponse(false, 400, 'Bad Request')), + clearBitStreamFormatRequests: observableOf(null) + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [AddBitstreamFormatComponent], + providers: [ + {provide: Router, useValue: router}, + {provide: NotificationsService, useValue: notificationService}, + {provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(initBeforeEach); + it('should send the updated form to the service, show a notification and navigate to ', () => { + comp.createBitstreamFormat(bitstreamFormat); + + expect(bitstreamFormatDataService.createBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat); + expect(notificationService.error).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + + }); + }); +}); diff --git a/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.ts b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.ts new file mode 100644 index 0000000000..9712be70ca --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.ts @@ -0,0 +1,49 @@ +import { take } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { Component } from '@angular/core'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; +import { RestResponse } from '../../../../core/cache/response.models'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * This component renders the page to create a new bitstream format. + */ +@Component({ + selector: 'ds-add-bitstream-format', + templateUrl: './add-bitstream-format.component.html', +}) +export class AddBitstreamFormatComponent { + + constructor( + private router: Router, + private notificationService: NotificationsService, + private translateService: TranslateService, + private bitstreamFormatDataService: BitstreamFormatDataService, + ) { + } + + /** + * Creates a new bitstream format based on the provided bitstream format emitted by the form. + * When successful, a success notification will be shown and the user will be navigated back to the overview page. + * When failed, an error notification will be shown. + * @param bitstreamFormat + */ + createBitstreamFormat(bitstreamFormat: BitstreamFormat) { + this.bitstreamFormatDataService.createBitstreamFormat(bitstreamFormat).pipe(take(1) + ).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationService.success(this.translateService.get('admin.registries.bitstream-formats.create.success.head'), + this.translateService.get('admin.registries.bitstream-formats.create.success.content')); + this.router.navigate([getBitstreamFormatsModulePath()]); + this.bitstreamFormatDataService.clearBitStreamFormatRequests().subscribe(); + } else { + this.notificationService.error(this.translateService.get('admin.registries.bitstream-formats.create.failure.head'), + this.translateService.get('admin.registries.bitstream-formats.create.failure.content')); + } + } + ); + } +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.actions.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.actions.ts new file mode 100644 index 0000000000..58b0686dfd --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.actions.ts @@ -0,0 +1,64 @@ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const BitstreamFormatsRegistryActionTypes = { + + SELECT_FORMAT: type('dspace/bitstream-formats-registry/SELECT_FORMAT'), + DESELECT_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_FORMAT'), + DESELECT_ALL_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_ALL_FORMAT') +}; + +/* tslint:disable:max-classes-per-file */ +/** + * Used to select a single bitstream format in the bitstream format registry + */ +export class BitstreamFormatsRegistrySelectAction implements Action { + type = BitstreamFormatsRegistryActionTypes.SELECT_FORMAT; + + bitstreamFormat: BitstreamFormat; + + constructor(bitstreamFormat: BitstreamFormat) { + this.bitstreamFormat = bitstreamFormat; + } +} + +/** + * Used to deselect a single bitstream format in the bitstream format registry + */ +export class BitstreamFormatsRegistryDeselectAction implements Action { + type = BitstreamFormatsRegistryActionTypes.DESELECT_FORMAT; + + bitstreamFormat: BitstreamFormat; + + constructor(bitstreamFormat: BitstreamFormat) { + this.bitstreamFormat = bitstreamFormat; + } +} + +/** + * Used to deselect all bitstream formats in the bitstream format registry + */ +export class BitstreamFormatsRegistryDeselectAllAction implements Action { + type = BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT; +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + * These are all the actions to perform on the bitstream format registry state + */ +export type BitstreamFormatsRegistryAction + = BitstreamFormatsRegistrySelectAction + | BitstreamFormatsRegistryDeselectAction + | BitstreamFormatsRegistryDeselectAllAction diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.reducers.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.reducers.spec.ts new file mode 100644 index 0000000000..76576afc7a --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.reducers.spec.ts @@ -0,0 +1,83 @@ +import { Action } from '@ngrx/store'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { bitstreamFormatReducer, BitstreamFormatRegistryState } from './bitstream-format.reducers'; +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction +} from './bitstream-format.actions'; + +const bitstreamFormat1: BitstreamFormat = new BitstreamFormat(); +bitstreamFormat1.id = 'test-uuid-1'; +bitstreamFormat1.shortDescription = 'test-short-1'; + +const bitstreamFormat2: BitstreamFormat = new BitstreamFormat(); +bitstreamFormat2.id = 'test-uuid-2'; +bitstreamFormat2.shortDescription = 'test-short-2'; + +const initialState: BitstreamFormatRegistryState = { + selectedBitstreamFormats: [] +}; + +const bitstream1SelectedState: BitstreamFormatRegistryState = { + selectedBitstreamFormats: [bitstreamFormat1] +}; + +const bitstream1and2SelectedState: BitstreamFormatRegistryState = { + selectedBitstreamFormats: [bitstreamFormat1, bitstreamFormat2] +}; + +describe('BitstreamFormatReducer', () => { + describe('BitstreamFormatsRegistryActionTypes.SELECT_FORMAT', () => { + it('should add the format to the list of selected formats when initial list is empty', () => { + const state = initialState; + const action = new BitstreamFormatsRegistrySelectAction(bitstreamFormat1); + const newState = bitstreamFormatReducer(state, action); + + expect(newState).toEqual(bitstream1SelectedState); + }); + it('should add the format to the list of selected formats when formats are already present', () => { + const state = bitstream1SelectedState; + const action = new BitstreamFormatsRegistrySelectAction(bitstreamFormat2); + const newState = bitstreamFormatReducer(state, action); + + expect(newState).toEqual(bitstream1and2SelectedState); + }); + }); + describe('BitstreamFormatsRegistryActionTypes.DESELECT_FORMAT', () => { + it('should deselect a format', () => { + const state = bitstream1and2SelectedState; + const action = new BitstreamFormatsRegistryDeselectAction(bitstreamFormat2); + const newState = bitstreamFormatReducer(state, action); + + expect(newState).toEqual(bitstream1SelectedState); + }); + }); + describe('BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT', () => { + it('should deselect all formats', () => { + const state = bitstream1and2SelectedState; + const action = new BitstreamFormatsRegistryDeselectAllAction(); + const newState = bitstreamFormatReducer(state, action); + + expect(newState).toEqual(initialState); + }); + }); + describe('Invalid action', () => { + it('should return the current state', () => { + const state = initialState; + const action = new NullAction(); + + const newState = bitstreamFormatReducer(state, action); + + expect(newState).toEqual(state); + }); + }); +}); + +class NullAction implements Action { + type = null; + + constructor() { + // empty constructor + } +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.reducers.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.reducers.ts new file mode 100644 index 0000000000..41880bf16c --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-format.reducers.ts @@ -0,0 +1,55 @@ +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { + BitstreamFormatsRegistryAction, + BitstreamFormatsRegistryActionTypes, + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistrySelectAction +} from './bitstream-format.actions'; + +/** + * The bitstream format registry state. + * @interface BitstreamFormatRegistryState + */ +export interface BitstreamFormatRegistryState { + selectedBitstreamFormats: BitstreamFormat[]; +} + +/** + * The initial state. + */ +const initialState: BitstreamFormatRegistryState = { + selectedBitstreamFormats: [], +}; + +/** + * Reducer that handles BitstreamFormatsRegistryActions to modify the bitstream format registry state + * @param state The current BitstreamFormatRegistryState + * @param action The BitstreamFormatsRegistryAction to perform on the state + */ +export function bitstreamFormatReducer(state = initialState, action: BitstreamFormatsRegistryAction): BitstreamFormatRegistryState { + + switch (action.type) { + + case BitstreamFormatsRegistryActionTypes.SELECT_FORMAT: { + return Object.assign({}, state, { + selectedBitstreamFormats: [...state.selectedBitstreamFormats, (action as BitstreamFormatsRegistrySelectAction).bitstreamFormat] + }); + } + + case BitstreamFormatsRegistryActionTypes.DESELECT_FORMAT: { + return Object.assign({}, state, { + selectedBitstreamFormats: state.selectedBitstreamFormats.filter( + (selectedBitstreamFormats) => selectedBitstreamFormats !== (action as BitstreamFormatsRegistryDeselectAction).bitstreamFormat + ) + }); + } + + case BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT: { + return Object.assign({}, state, { + selectedBitstreamFormats: [] + }); + } + default: + return state; + } +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats-routing.module.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats-routing.module.ts new file mode 100644 index 0000000000..67f6aa373e --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats-routing.module.ts @@ -0,0 +1,37 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { BitstreamFormatsResolver } from './bitstream-formats.resolver'; +import { EditBitstreamFormatComponent } from './edit-bitstream-format/edit-bitstream-format.component'; +import { BitstreamFormatsComponent } from './bitstream-formats.component'; +import { AddBitstreamFormatComponent } from './add-bitstream-format/add-bitstream-format.component'; + +const BITSTREAMFORMAT_EDIT_PATH = ':id/edit'; +const BITSTREAMFORMAT_ADD_PATH = 'add'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: BitstreamFormatsComponent + }, + { + path: BITSTREAMFORMAT_ADD_PATH, + component: AddBitstreamFormatComponent, + }, + { + path: BITSTREAMFORMAT_EDIT_PATH, + component: EditBitstreamFormatComponent, + resolve: { + bitstreamFormat: BitstreamFormatsResolver + } + }, + ]) + ], + providers: [ + BitstreamFormatsResolver, + ] +}) +export class BitstreamFormatsRoutingModule { + +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.html index 1ac547653f..e5cf7cf5ec 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.html +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.html @@ -2,13 +2,15 @@
- + + +

{{'admin.registries.bitstream-formats.description' | translate}}

+

{{'admin.registries.bitstream-formats.create.new' | translate}}

-

{{'admin.registries.bitstream-formats.description' | translate}}

- {{'admin.registries.bitstream-formats.formats.table.name' | translate}} - {{'admin.registries.bitstream-formats.formats.table.mimetype' | translate}} - {{'admin.registries.bitstream-formats.formats.table.supportLevel.head' | translate}} + + {{'admin.registries.bitstream-formats.table.name' | translate}} + {{'admin.registries.bitstream-formats.table.mimetype' | translate}} + {{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}} - {{bitstreamFormat.shortDescription}} - {{bitstreamFormat.mimetype}} ({{'admin.registries.bitstream-formats.formats.table.internal' | translate}}) - {{'admin.registries.bitstream-formats.formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}} + + + + {{bitstreamFormat.shortDescription}} + {{bitstreamFormat.mimetype}} ({{'admin.registries.bitstream-formats.table.internal' | translate}}) + {{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}
+
+ + +
diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index 3a680c906b..e672dc82ea 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -1,6 +1,5 @@ import { BitstreamFormatsComponent } from './bitstream-formats.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { RegistryService } from '../../../core/registry/registry.service'; import { of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; @@ -13,86 +12,278 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; import { HostWindowService } from '../../../shared/host-window.service'; import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; +import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../../../core/shared/bitstream-format-support-level'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; describe('BitstreamFormatsComponent', () => { let comp: BitstreamFormatsComponent; let fixture: ComponentFixture; - let registryService: RegistryService; - const mockFormatsList = [ - { - shortDescription: 'Unknown', - description: 'Unknown data format', - mimetype: 'application/octet-stream', - supportLevel: 0, - internal: false, - extensions: null - }, - { - shortDescription: 'License', - description: 'Item-specific license agreed upon to submission', - mimetype: 'text/plain; charset=utf-8', - supportLevel: 1, - internal: true, - extensions: null - }, - { - shortDescription: 'CC License', - description: 'Item-specific Creative Commons license agreed upon to submission', - mimetype: 'text/html; charset=utf-8', - supportLevel: 2, - internal: true, - extensions: null - }, - { - shortDescription: 'Adobe PDF', - description: 'Adobe Portable Document Format', - mimetype: 'application/pdf', - supportLevel: 0, - internal: false, - extensions: null - } - ]; - const mockFormats = createSuccessfulRemoteDataObject$(new PaginatedList(null, mockFormatsList)); - const registryServiceStub = { - getBitstreamFormats: () => mockFormats - }; + let bitstreamFormatService; + let scheduler: TestScheduler; + let notificationsServiceStub; + + const bitstreamFormat1 = new BitstreamFormat(); + bitstreamFormat1.uuid = 'test-uuid-1'; + bitstreamFormat1.id = 'test-uuid-1'; + bitstreamFormat1.shortDescription = 'Unknown'; + bitstreamFormat1.description = 'Unknown data format'; + bitstreamFormat1.mimetype = 'application/octet-stream'; + bitstreamFormat1.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat1.internal = false; + bitstreamFormat1.extensions = null; + + const bitstreamFormat2 = new BitstreamFormat(); + bitstreamFormat2.uuid = 'test-uuid-2'; + bitstreamFormat2.id = 'test-uuid-2'; + bitstreamFormat2.shortDescription = 'License'; + bitstreamFormat2.description = 'Item-specific license agreed upon to submission'; + bitstreamFormat2.mimetype = 'text/plain; charset=utf-8'; + bitstreamFormat2.supportLevel = BitstreamFormatSupportLevel.Known; + bitstreamFormat2.internal = true; + bitstreamFormat2.extensions = null; + + const bitstreamFormat3 = new BitstreamFormat(); + bitstreamFormat3.uuid = 'test-uuid-3'; + bitstreamFormat3.id = 'test-uuid-3'; + bitstreamFormat3.shortDescription = 'CC License'; + bitstreamFormat3.description = 'Item-specific Creative Commons license agreed upon to submission'; + bitstreamFormat3.mimetype = 'text/html; charset=utf-8'; + bitstreamFormat3.supportLevel = BitstreamFormatSupportLevel.Supported; + bitstreamFormat3.internal = true; + bitstreamFormat3.extensions = null; + + const bitstreamFormat4 = new BitstreamFormat(); + bitstreamFormat4.uuid = 'test-uuid-4'; + bitstreamFormat4.id = 'test-uuid-4'; + bitstreamFormat4.shortDescription = 'Adobe PDF'; + bitstreamFormat4.description = 'Adobe Portable Document Format'; + bitstreamFormat4.mimetype = 'application/pdf'; + bitstreamFormat4.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat4.internal = false; + bitstreamFormat4.extensions = null; + + const mockFormatsList: BitstreamFormat[] = [ + bitstreamFormat1, + bitstreamFormat2, + bitstreamFormat3, + bitstreamFormat4 + ]; + const mockFormatsRD = new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList)); + + const initAsync = () => { + notificationsServiceStub = new NotificationsServiceStub(); + + scheduler = getTestScheduler(); + + bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { + findAll: observableOf(mockFormatsRD), + find: observableOf(new RemoteData(false, false, true, undefined, mockFormatsList[0])), + getSelectedBitstreamFormats: hot('a', {a: mockFormatsList}), + selectBitstreamFormat: {}, + deselectBitstreamFormat: {}, + deselectAllBitstreamFormats: {}, + delete: observableOf(true), + clearBitStreamFormatRequests: observableOf('cleared') + }); - beforeEach(async(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe], providers: [ - { provide: RegistryService, useValue: registryServiceStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) } + {provide: BitstreamFormatDataService, useValue: bitstreamFormatService}, + {provide: HostWindowService, useValue: new HostWindowServiceStub(0)}, + {provide: NotificationsService, useValue: notificationsServiceStub} ] }).compileComponents(); - })); + }; - beforeEach(() => { + const initBeforeEach = () => { fixture = TestBed.createComponent(BitstreamFormatsComponent); comp = fixture.componentInstance; fixture.detectChanges(); - registryService = (comp as any).service; + }; + + describe('Bitstream format page content', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + + it('should contain four formats', () => { + const tbody: HTMLElement = fixture.debugElement.query(By.css('#formats>tbody')).nativeElement; + expect(tbody.children.length).toBe(4); + }); + + it('should contain the correct formats', () => { + const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement; + expect(unknownName.textContent).toBe('Unknown'); + + const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(2)')).nativeElement; + expect(licenseName.textContent).toBe('License'); + + const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(2)')).nativeElement; + expect(ccLicenseName.textContent).toBe('CC License'); + + const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(2)')).nativeElement; + expect(adobeName.textContent).toBe('Adobe PDF'); + }); }); - it('should contain four formats', () => { - const tbody: HTMLElement = fixture.debugElement.query(By.css('#formats>tbody')).nativeElement; - expect(tbody.children.length).toBe(4); + describe('selectBitStreamFormat', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + it('should select a bitstreamFormat if it was selected in the event', () => { + const event = {target: {checked: true}}; + + comp.selectBitStreamFormat(bitstreamFormat1, event); + + expect(bitstreamFormatService.selectBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat1); + }); + it('should deselect a bitstreamFormat if it is deselected in the event', () => { + const event = {target: {checked: false}}; + + comp.selectBitStreamFormat(bitstreamFormat1, event); + + expect(bitstreamFormatService.deselectBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat1); + }); + it('should be called when a user clicks a checkbox', () => { + spyOn(comp, 'selectBitStreamFormat'); + const unknownFormat = fixture.debugElement.query(By.css('#formats tr:nth-child(1) input')); + + const event = {target: {checked: true}}; + unknownFormat.triggerEventHandler('change', event); + + expect(comp.selectBitStreamFormat).toHaveBeenCalledWith(bitstreamFormat1, event); + }); }); - it('should contain the correct formats', () => { - const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(1)')).nativeElement; - expect(unknownName.textContent).toBe('Unknown'); + describe('isSelected', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + it('should return an observable of true if the provided bistream is in the list returned by the service', () => { + const result = comp.isSelected(bitstreamFormat1); - const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(1)')).nativeElement; - expect(licenseName.textContent).toBe('License'); + expect(result).toBeObservable(cold('b', {b: true})); + }); + it('should return an observable of false if the provided bistream is not in the list returned by the service', () => { + const format = new BitstreamFormat(); + format.uuid = 'new'; - const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(1)')).nativeElement; - expect(ccLicenseName.textContent).toBe('CC License'); + const result = comp.isSelected(format); - const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(1)')).nativeElement; - expect(adobeName.textContent).toBe('Adobe PDF'); + expect(result).toBeObservable(cold('b', {b: false})); + }); }); + describe('deselectAll', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + it('should deselect all bitstreamFormats', () => { + comp.deselectAll(); + expect(bitstreamFormatService.deselectAllBitstreamFormats).toHaveBeenCalled(); + }); + + it('should be called when the deselect all button is clicked', () => { + spyOn(comp, 'deselectAll'); + const deselectAllButton = fixture.debugElement.query(By.css('button.deselect')); + deselectAllButton.triggerEventHandler('click', null); + + expect(comp.deselectAll).toHaveBeenCalled(); + + }); + }); + + describe('deleteFormats success', () => { + beforeEach(async(() => { + notificationsServiceStub = new NotificationsServiceStub(); + + scheduler = getTestScheduler(); + + bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { + findAll: observableOf(mockFormatsRD), + find: observableOf(new RemoteData(false, false, true, undefined, mockFormatsList[0])), + getSelectedBitstreamFormats: observableOf(mockFormatsList), + selectBitstreamFormat: {}, + deselectBitstreamFormat: {}, + deselectAllBitstreamFormats: {}, + delete: observableOf(true), + clearBitStreamFormatRequests: observableOf('cleared') + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe], + providers: [ + {provide: BitstreamFormatDataService, useValue: bitstreamFormatService}, + {provide: HostWindowService, useValue: new HostWindowServiceStub(0)}, + {provide: NotificationsService, useValue: notificationsServiceStub} + ] + }).compileComponents(); + } + )); + + beforeEach(initBeforeEach); + it('should clear bitstream formats ', () => { + comp.deleteFormats(); + + expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4); + + expect(notificationsServiceStub.success).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.success.head', + 'admin.registries.bitstream-formats.delete.success.amount'); + expect(notificationsServiceStub.error).not.toHaveBeenCalled(); + + }); + }); + + describe('deleteFormats error', () => { + beforeEach(async(() => { + notificationsServiceStub = new NotificationsServiceStub(); + + scheduler = getTestScheduler(); + + bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { + findAll: observableOf(mockFormatsRD), + find: observableOf(new RemoteData(false, false, true, undefined, mockFormatsList[0])), + getSelectedBitstreamFormats: observableOf(mockFormatsList), + selectBitstreamFormat: {}, + deselectBitstreamFormat: {}, + deselectAllBitstreamFormats: {}, + delete: observableOf(false), + clearBitStreamFormatRequests: observableOf('cleared') + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe], + providers: [ + {provide: BitstreamFormatDataService, useValue: bitstreamFormatService}, + {provide: HostWindowService, useValue: new HostWindowServiceStub(0)}, + {provide: NotificationsService, useValue: notificationsServiceStub} + ] + }).compileComponents(); + } + )); + + beforeEach(initBeforeEach); + it('should clear bitstream formats ', () => { + comp.deleteFormats(); + + expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3); + expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4); + + expect(notificationsServiceStub.error).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.failure.head', + 'admin.registries.bitstream-formats.delete.failure.amount'); + expect(notificationsServiceStub.success).not.toHaveBeenCalled(); + }); + }); }); 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 bc0cbb8da6..cb7aa1ef91 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 @@ -1,10 +1,16 @@ -import { Component } from '@angular/core'; -import { RegistryService } from '../../../core/registry/registry.service'; -import { Observable } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, zip } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; 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'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; +import { FindAllOptions } from '../../../core/data/request.models'; +import { map, switchMap, take } from 'rxjs/operators'; +import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; /** * This component renders a list of bitstream formats @@ -13,24 +19,125 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio selector: 'ds-bitstream-formats', templateUrl: './bitstream-formats.component.html' }) -export class BitstreamFormatsComponent { +export class BitstreamFormatsComponent implements OnInit { /** * A paginated list of bitstream formats to be shown on the page */ bitstreamFormats: Observable>>; + /** + * A BehaviourSubject that keeps track of the pageState used to update the currently displayed bitstreamFormats + */ + pageState: BehaviorSubject; + + /** + * The current pagination configuration for the page used by the FindAll method + * Currently simply renders all bitstream formats + */ + config: FindAllOptions = Object.assign(new FindAllOptions(), { + elementsPerPage: 20 + }); + /** * The current pagination configuration for the page * Currently simply renders all bitstream formats */ - config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'registry-bitstreamformats-pagination', - pageSize: 10000 + pageSize: 20 }); - constructor(private registryService: RegistryService) { - this.updateFormats(); + constructor(private notificationsService: NotificationsService, + private router: Router, + private translateService: TranslateService, + private bitstreamFormatService: BitstreamFormatDataService) { + } + + /** + * Deletes the currently selected formats from the registry and updates the presented list + */ + deleteFormats() { + this.bitstreamFormatService.clearBitStreamFormatRequests().subscribe(); + this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(take(1)).subscribe( + (formats) => { + const tasks$ = []; + for (const format of formats) { + if (hasValue(format.id)) { + tasks$.push(this.bitstreamFormatService.delete(format)); + } + } + zip(...tasks$).subscribe((results: boolean[]) => { + const successResponses = results.filter((result: boolean) => result); + const failedResponses = results.filter((result: boolean) => !result); + if (successResponses.length > 0) { + this.showNotification(true, successResponses.length); + } + if (failedResponses.length > 0) { + this.showNotification(false, failedResponses.length); + } + + this.deselectAll(); + + this.router.navigate([], { + queryParams: Object.assign({}, { page: 1 }), + queryParamsHandling: 'merge' + }); }); + } + ); + } + + /** + * Deselects all selecetd bitstream formats + */ + deselectAll() { + this.bitstreamFormatService.deselectAllBitstreamFormats(); + } + + /** + * Checks whether a given bitstream format is selected in the list (checkbox) + * @param bitstreamFormat + */ + isSelected(bitstreamFormat: BitstreamFormat): Observable { + return this.bitstreamFormatService.getSelectedBitstreamFormats().pipe( + map((bitstreamFormats: BitstreamFormat[]) => { + return bitstreamFormats.find((selectedFormat) => selectedFormat.id === bitstreamFormat.id) != null; + }) + ); + } + + /** + * Selects or deselects a bitstream format based on the checkbox state + * @param bitstreamFormat + * @param event + */ + selectBitStreamFormat(bitstreamFormat: BitstreamFormat, event) { + event.target.checked ? + this.bitstreamFormatService.selectBitstreamFormat(bitstreamFormat) : + this.bitstreamFormatService.deselectBitstreamFormat(bitstreamFormat); + } + + /** + * Show notifications for an amount of deleted bitstream formats + * @param success Whether or not the notification should be a success message (error message when false) + * @param amount The amount of deleted bitstream formats + */ + private showNotification(success: boolean, amount: number) { + const prefix = 'admin.registries.bitstream-formats.delete'; + const suffix = success ? 'success' : 'failure'; + + const messages = observableCombineLatest( + this.translateService.get(`${prefix}.${suffix}.head`), + this.translateService.get(`${prefix}.${suffix}.amount`, {amount: amount}) + ); + messages.subscribe(([head, content]) => { + + if (success) { + this.notificationsService.success(head, content); + } else { + this.notificationsService.error(head, content); + } + }); } /** @@ -38,14 +145,26 @@ export class BitstreamFormatsComponent { * @param event The page change event */ onPageChange(event) { - this.config.currentPage = event; - this.updateFormats(); + this.config = Object.assign(new FindAllOptions(), this.config, { + currentPage: event, + }); + this.pageConfig.currentPage = event; + this.pageState.next('pageChange'); + } + + ngOnInit(): void { + this.pageState = new BehaviorSubject('init'); + this.bitstreamFormats = this.pageState.pipe( + switchMap(() => { + return this.updateFormats() + ; + })); } /** - * Method to update the bitstream formats that are shown + * Finds all formats based on the current config */ private updateFormats() { - this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config); + return this.bitstreamFormatService.findAll(this.config); } } diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.module.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.module.ts new file mode 100644 index 0000000000..0800c50169 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { BitstreamFormatsComponent } from './bitstream-formats.component'; +import { SharedModule } from '../../../shared/shared.module'; +import { FormatFormComponent } from './format-form/format-form.component'; +import { EditBitstreamFormatComponent } from './edit-bitstream-format/edit-bitstream-format.component'; +import { BitstreamFormatsRoutingModule } from './bitstream-formats-routing.module'; +import { AddBitstreamFormatComponent } from './add-bitstream-format/add-bitstream-format.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RouterModule, + TranslateModule, + BitstreamFormatsRoutingModule + ], + declarations: [ + BitstreamFormatsComponent, + EditBitstreamFormatComponent, + AddBitstreamFormatComponent, + FormatFormComponent + ], + entryComponents: [] +}) +export class BitstreamFormatsModule { + +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.resolver.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.resolver.ts new file mode 100644 index 0000000000..f6eef741fd --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.resolver.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; +import { hasValue } from '../../../shared/empty.util'; + +/** + * This class represents a resolver that requests a specific bitstreamFormat before the route is activated + */ +@Injectable() +export class BitstreamFormatsResolver implements Resolve> { + constructor(private bitstreamFormatDataService: BitstreamFormatDataService) { + } + + /** + * Method for resolving an bitstreamFormat 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 bitstreamFormat based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + return this.bitstreamFormatDataService.findById(route.params.id) + .pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); + } +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.html b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.html new file mode 100644 index 0000000000..f57ec9cd38 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.html @@ -0,0 +1,11 @@ +
+
+
+

{{'admin.registries.bitstream-formats.edit.head' | translate:{format: (bitstreamFormatRD$ | async)?.payload.shortDescription} }}

+ + + +
+
+
\ No newline at end of file diff --git a/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.spec.ts new file mode 100644 index 0000000000..cfa93a15a8 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.spec.ts @@ -0,0 +1,123 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { RouterStub } from '../../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { EditBitstreamFormatComponent } from './edit-bitstream-format.component'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service-stub'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; +import { RestResponse } from '../../../../core/cache/response.models'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level'; +import { ResourceType } from '../../../../core/shared/resource-type'; + +describe('EditBitstreamFormatComponent', () => { + let comp: EditBitstreamFormatComponent; + let fixture: ComponentFixture; + + const bitstreamFormat = new BitstreamFormat(); + bitstreamFormat.uuid = 'test-uuid-1'; + bitstreamFormat.id = 'test-uuid-1'; + bitstreamFormat.shortDescription = 'Unknown'; + bitstreamFormat.description = 'Unknown data format'; + bitstreamFormat.mimetype = 'application/octet-stream'; + bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat.internal = false; + bitstreamFormat.extensions = null; + + const routeStub = { + data: observableOf({ + bitstreamFormat: new RemoteData(false, false, true, null, bitstreamFormat) + }) + }; + + let router; + let notificationService: NotificationsServiceStub; + let bitstreamFormatDataService: BitstreamFormatDataService; + + const initAsync = () => { + router = new RouterStub(); + notificationService = new NotificationsServiceStub(); + bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', { + updateBitstreamFormat: observableOf(new RestResponse(true, 200, 'Success')) + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [EditBitstreamFormatComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: router}, + {provide: NotificationsService, useValue: notificationService}, + {provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + }; + + const initBeforeEach = () => { + fixture = TestBed.createComponent(EditBitstreamFormatComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }; + + describe('init', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + it('should initialise the bitstreamFormat based on the route', () => { + + comp.bitstreamFormatRD$.subscribe((format: RemoteData) => { + expect(format).toEqual(new RemoteData(false, false, true, null, bitstreamFormat)); + }); + }); + }); + describe('updateFormat success', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + it('should send the updated form to the service, show a notification and navigate to ', () => { + comp.updateFormat(bitstreamFormat); + + expect(bitstreamFormatDataService.updateBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat); + expect(notificationService.success).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/admin/registries/bitstream-formats']); + + }); + }); + describe('updateFormat error', () => { + beforeEach(async( () => { + router = new RouterStub(); + notificationService = new NotificationsServiceStub(); + bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', { + updateBitstreamFormat: observableOf(new RestResponse(false, 400, 'Bad Request')) + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [EditBitstreamFormatComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: router}, + {provide: NotificationsService, useValue: notificationService}, + {provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(initBeforeEach); + it('should send the updated form to the service, show a notification and navigate to ', () => { + comp.updateFormat(bitstreamFormat); + + expect(bitstreamFormatDataService.updateBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat); + expect(notificationService.error).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + + }); + }); +}); diff --git a/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.ts b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.ts new file mode 100644 index 0000000000..0fdcc75689 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.ts @@ -0,0 +1,62 @@ +import { map, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; +import { RestResponse } from '../../../../core/cache/response.models'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * This component renders the edit page of a bitstream format. + * The route parameter 'id' is used to request the bitstream format. + */ +@Component({ + selector: 'ds-edit-bitstream-format', + templateUrl: './edit-bitstream-format.component.html', +}) +export class EditBitstreamFormatComponent implements OnInit { + + /** + * The bitstream format wrapped in a remote-data object + */ + bitstreamFormatRD$: Observable>; + + constructor( + private route: ActivatedRoute, + private router: Router, + private notificationService: NotificationsService, + private translateService: TranslateService, + private bitstreamFormatDataService: BitstreamFormatDataService, + ) { + } + + ngOnInit(): void { + this.bitstreamFormatRD$ = this.route.data.pipe( + map((data) => data.bitstreamFormat as RemoteData) + ); + } + + /** + * Updates the bitstream format based on the provided bitstream format emitted by the form. + * When successful, a success notification will be shown and the user will be navigated back to the overview page. + * When failed, an error notification will be shown. + */ + updateFormat(bitstreamFormat: BitstreamFormat) { + this.bitstreamFormatDataService.updateBitstreamFormat(bitstreamFormat).pipe(take(1) + ).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationService.success(this.translateService.get('admin.registries.bitstream-formats.edit.success.head'), + this.translateService.get('admin.registries.bitstream-formats.edit.success.content')); + this.router.navigate([getBitstreamFormatsModulePath()]); + } else { + this.notificationService.error('admin.registries.bitstream-formats.edit.failure.head', + 'admin.registries.bitstream-formats.create.edit.content'); + } + } + ); + } +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.html b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.html new file mode 100644 index 0000000000..be6ebf2599 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.spec.ts new file mode 100644 index 0000000000..2870705fc8 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.spec.ts @@ -0,0 +1,104 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { RouterStub } from '../../../../shared/testing/router-stub'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FormatFormComponent } from './format-form.component'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level'; +import { DynamicCheckboxModel, DynamicFormArrayModel, DynamicInputModel } from '@ng-dynamic-forms/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { isEmpty } from '../../../../shared/empty.util'; + +describe('FormatFormComponent', () => { + let comp: FormatFormComponent; + let fixture: ComponentFixture; + + const router = new RouterStub(); + + const bitstreamFormat = new BitstreamFormat(); + bitstreamFormat.uuid = 'test-uuid-1'; + bitstreamFormat.id = 'test-uuid-1'; + bitstreamFormat.shortDescription = 'Unknown'; + bitstreamFormat.description = 'Unknown data format'; + bitstreamFormat.mimetype = 'application/octet-stream'; + bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat.internal = false; + bitstreamFormat.extensions = []; + + const submittedBitstreamFormat = new BitstreamFormat(); + submittedBitstreamFormat.id = bitstreamFormat.id; + submittedBitstreamFormat.shortDescription = bitstreamFormat.shortDescription; + submittedBitstreamFormat.mimetype = bitstreamFormat.mimetype; + submittedBitstreamFormat.description = bitstreamFormat.description; + submittedBitstreamFormat.supportLevel = bitstreamFormat.supportLevel; + submittedBitstreamFormat.internal = bitstreamFormat.internal; + submittedBitstreamFormat.extensions = bitstreamFormat.extensions; + + const initAsync = () => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), ReactiveFormsModule, FormsModule, TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [FormatFormComponent], + providers: [ + {provide: Router, useValue: router}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + }; + + const initBeforeEach = () => { + fixture = TestBed.createComponent(FormatFormComponent); + comp = fixture.componentInstance; + + comp.bitstreamFormat = bitstreamFormat; + fixture.detectChanges(); + }; + + describe('initialise', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + it('should initialises the values in the form', () => { + + expect((comp.formModel[0] as DynamicInputModel).value).toBe(bitstreamFormat.shortDescription); + expect((comp.formModel[1] as DynamicInputModel).value).toBe(bitstreamFormat.mimetype); + expect((comp.formModel[2] as DynamicInputModel).value).toBe(bitstreamFormat.description); + expect((comp.formModel[3] as DynamicInputModel).value).toBe(bitstreamFormat.supportLevel); + expect((comp.formModel[4] as DynamicCheckboxModel).value).toBe(bitstreamFormat.internal); + + const formArray = (comp.formModel[5] as DynamicFormArrayModel); + const extensions = []; + for (let i = 0; i < formArray.groups.length; i++) { + const value = (formArray.get(i).get(0) as DynamicInputModel).value; + if (!isEmpty(value)) { + extensions.push((formArray.get(i).get(0) as DynamicInputModel).value); + } + } + + expect(extensions).toEqual(bitstreamFormat.extensions); + + }); + }); + describe('onSubmit', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + + it('should emit the bitstreamFormat currently present in the form', () => { + spyOn(comp.updatedFormat, 'emit'); + comp.onSubmit(); + + expect(comp.updatedFormat.emit).toHaveBeenCalledWith(submittedBitstreamFormat); + }); + }); + describe('onCancel', () => { + beforeEach(async(initAsync)); + beforeEach(initBeforeEach); + + it('should navigate back to the bitstream overview', () => { + comp.onCancel(); + expect(router.navigate).toHaveBeenCalledWith(['/admin/registries/bitstream-formats']); + }); + }); +}); diff --git a/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.ts b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.ts new file mode 100644 index 0000000000..505ccccd91 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.ts @@ -0,0 +1,194 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level'; +import { + DynamicCheckboxModel, + DynamicFormArrayModel, + DynamicFormControlLayout, DynamicFormControlLayoutConfig, + DynamicFormControlModel, + DynamicFormService, + DynamicInputModel, + DynamicSelectModel, + DynamicTextAreaModel +} from '@ng-dynamic-forms/core'; +import { Router } from '@angular/router'; +import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module'; +import { hasValue, isEmpty } from '../../../../shared/empty.util'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * The component responsible for rendering the form to create/edit a bitstream format + */ +@Component({ + selector: 'ds-bitstream-format-form', + templateUrl: './format-form.component.html' +}) +export class FormatFormComponent implements OnInit { + + /** + * The current bitstream format + * This can either be and existing one or a new one + */ + @Input() bitstreamFormat: BitstreamFormat = new BitstreamFormat(); + + /** + * EventEmitter that will emit the updated bitstream format + */ + @Output() updatedFormat: EventEmitter = new EventEmitter(); + + /** + * The different supported support level of the bitstream format + */ + supportLevelOptions = [{label: BitstreamFormatSupportLevel.Known, value: BitstreamFormatSupportLevel.Known}, + {label: BitstreamFormatSupportLevel.Unknown, value: BitstreamFormatSupportLevel.Unknown}, + {label: BitstreamFormatSupportLevel.Supported, value: BitstreamFormatSupportLevel.Supported}]; + + /** + * Styling element for repeatable field + */ + arrayElementLayout: DynamicFormControlLayout = { + grid: { + group: 'form-row', + }, + }; + + /** + * Styling element for element of repeatable field + */ + arrayInputElementLayout: DynamicFormControlLayout = { + grid: { + host: 'col' + } + }; + + /** + * The form model representing the bitstream format + */ + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'shortDescription', + name: 'shortDescription', + label: 'admin.registries.bitstream-formats.edit.shortDescription.label', + hint: 'admin.registries.bitstream-formats.edit.shortDescription.hint', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'Please enter a name for this bitstream format' + }, + }), + new DynamicInputModel({ + id: 'mimetype', + name: 'mimetype', + label: 'admin.registries.bitstream-formats.edit.mimetype.label', + hint: 'admin.registries.bitstream-formats.edit.mimetype.hint', + + }), + new DynamicTextAreaModel({ + id: 'description', + name: 'description', + label: 'admin.registries.bitstream-formats.edit.description.label', + hint: 'admin.registries.bitstream-formats.edit.description.hint', + + }), + new DynamicSelectModel({ + id: 'supportLevel', + name: 'supportLevel', + options: this.supportLevelOptions, + label: 'admin.registries.bitstream-formats.edit.supportLevel.label', + hint: 'admin.registries.bitstream-formats.edit.supportLevel.hint', + value: this.supportLevelOptions[0].value + + }), + new DynamicCheckboxModel({ + id: 'internal', + name: 'internal', + label: 'Internal', + hint: 'admin.registries.bitstream-formats.edit.internal.hint', + }), + new DynamicFormArrayModel({ + id: 'extensions', + name: 'extensions', + label: 'admin.registries.bitstream-formats.edit.extensions.label', + groupFactory: () => [ + new DynamicInputModel({ + id: 'extension', + placeholder: 'admin.registries.bitstream-formats.edit.extensions.placeholder', + }, this.arrayInputElementLayout) + ] + }, this.arrayElementLayout), + ]; + + constructor(private dynamicFormService: DynamicFormService, + private translateService: TranslateService, + private router: Router) { + + } + + ngOnInit(): void { + + this.initValues(); + } + + /** + * Initializes the form based on the provided bitstream format + */ + initValues() { + this.formModel.forEach( + (fieldModel: DynamicFormControlModel) => { + if (fieldModel.name === 'extensions') { + if (hasValue(this.bitstreamFormat.extensions)) { + const extenstions = this.bitstreamFormat.extensions; + const formArray = (fieldModel as DynamicFormArrayModel); + for (let i = 0; i < extenstions.length; i++) { + formArray.insertGroup(i).group[0] = new DynamicInputModel({ + id: `extension-${i}`, + value: extenstions[i] + }, this.arrayInputElementLayout); + } + } + } else { + if (hasValue(this.bitstreamFormat[fieldModel.name])) { + (fieldModel as DynamicInputModel).value = this.bitstreamFormat[fieldModel.name]; + } + } + }); + } + + /** + * Creates an updated bistream format based on the current values in the form + * Emits the updated bitstream format trouhg the updatedFormat emitter + */ + onSubmit() { + const updatedBitstreamFormat = Object.assign(new BitstreamFormat(), + { + id: this.bitstreamFormat.id + }); + + this.formModel.forEach( + (fieldModel: DynamicFormControlModel) => { + if (fieldModel.name === 'extensions') { + const formArray = (fieldModel as DynamicFormArrayModel); + const extensions = []; + for (let i = 0; i < formArray.groups.length; i++) { + const value = (formArray.get(i).get(0) as DynamicInputModel).value; + if (!isEmpty(value)) { + extensions.push((formArray.get(i).get(0) as DynamicInputModel).value); + } + } + updatedBitstreamFormat.extensions = extensions; + } else { + updatedBitstreamFormat[fieldModel.name] = (fieldModel as DynamicInputModel).value; + } + }); + this.updatedFormat.emit(updatedBitstreamFormat); + } + + /** + * Cancels the edit/create action of the bitstream format and navigates back to the bitstream format registry + */ + onCancel() { + this.router.navigate([getBitstreamFormatsModulePath()]); + } +} diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index 71af51c683..2003ecf124 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -1,11 +1,19 @@ import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getAdminModulePath } from '../app-routing.module'; + +const REGISTRIES_MODULE_PATH = 'registries'; + +export function getRegistriesModulePath() { + return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString(); +} @NgModule({ imports: [ RouterModule.forChild([ { - path: 'registries', + path: REGISTRIES_MODULE_PATH, loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' } ]) diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 91239de17c..2b16bc1ca6 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -52,6 +52,9 @@ message="{{'error.recent-submissions' | translate}}"> + ; + /** + * The current url of this page + */ + url: string; + /** + * Prefix for this component's notification translate keys + * Should be initialized in the initializeNotificationsPrefix method of the child component + */ + notificationsPrefix; + /** + * The time span for being able to undo discarding changes + */ + discardTimeOut: number; + + constructor( + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected route: ActivatedRoute + ) { + + } + + /** + * Initialize common properties between item-update components + */ + ngOnInit(): void { + this.route.parent.data.pipe(map((data) => data.item)) + .pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.item = item; + }); + + this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); + } + this.hasChanges().pipe(first()).subscribe((hasChanges) => { + if (!hasChanges) { + this.initializeOriginalFields(); + } else { + this.checkLastModified(); + } + }); + + this.initializeNotificationsPrefix(); + this.initializeUpdates(); + } + + /** + * Initialize the values and updates of the current item's fields + */ + abstract initializeUpdates(): void; + + /** + * Initialize the prefix for notification messages + */ + abstract initializeNotificationsPrefix(): void; + + /** + * Sends all initial values of this item to the object updates service + */ + abstract initializeOriginalFields(): void; + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + + /** + * Checks whether or not there are currently updates for this item + */ + hasChanges(): Observable { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Check if the current page is entirely valid + */ + protected isValid() { + return this.objectUpdatesService.isValidPage(this.url); + } + + /** + * Checks if the current item is still in sync with the version in the store + * If it's not, a notification is shown and the changes are removed + */ + private checkLastModified() { + const currentVersion = this.item.lastModified; + this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( + (updateVersion: Date) => { + if (updateVersion.getDate() !== currentVersion.getDate()) { + this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); + this.initializeOriginalFields(); + } + } + ); + } + + /** + * Submit the current changes + */ + abstract submit(): void; + + /** + * Request the object updates service to discard all current changes to this item + * Shows a notification to remind the user that they can undo this + */ + discard() { + const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); + this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); + } + + /** + * Request the object updates service to undo discarding all changes to this item + */ + reinstate() { + this.objectUpdatesService.reinstateFieldUpdates(this.url); + } + + /** + * Checks whether or not the item is currently reinstatable + */ + isReinstatable(): Observable { + return this.objectUpdatesService.isReinstatable(this.url); + } + + /** + * Get translated notification title + * @param key + */ + protected getNotificationTitle(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + protected getNotificationContent(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 0c1de642ce..1542d12ce5 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -15,6 +15,9 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; +import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component'; +import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -37,8 +40,11 @@ import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.compo ItemDeleteComponent, ItemStatusComponent, ItemMetadataComponent, + ItemRelationshipsComponent, ItemBitstreamsComponent, - EditInPlaceFieldComponent + EditInPlaceFieldComponent, + EditRelationshipComponent, + EditRelationshipListComponent ] }) export class EditItemPageModule { diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 811965e145..f298f7df39 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -10,6 +10,7 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -50,6 +51,11 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; component: ItemMetadataComponent, data: { title: 'item.edit.tabs.metadata.title' } }, + { + path: 'relationships', + component: ItemRelationshipsComponent, + data: { title: 'item.edit.tabs.relationships.title' } + }, { path: 'view', /* TODO - change when view page exists */ diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts index e51182e7b3..0c164b7bc2 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts @@ -31,7 +31,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; -let comp: ItemMetadataComponent; +let comp: any; let fixture: ComponentFixture; let de: DebugElement; let el: HTMLElement; diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 8148b89bd4..be657d71dc 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; @@ -6,8 +6,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { cloneDeep } from 'lodash'; import { Observable } from 'rxjs'; import { - FieldUpdate, - FieldUpdates, Identifiable } from '../../../core/data/object-updates/object-updates.reducer'; import { first, map, switchMap, take, tap } from 'rxjs/operators'; @@ -19,6 +17,7 @@ import { TranslateService } from '@ngx-translate/core'; import { RegistryService } from '../../../core/registry/registry.service'; import { MetadatumViewModel } from '../../../core/shared/metadata.models'; import { Metadata } from '../../../core/shared/metadata.utils'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { MetadataField } from '../../../core/metadata/metadata-field.model'; @Component({ @@ -29,28 +28,7 @@ import { MetadataField } from '../../../core/metadata/metadata-field.model'; /** * Component for displaying an item's metadata edit page */ -export class ItemMetadataComponent implements OnInit { - - /** - * The item to display the edit page for - */ - item: Item; - /** - * The current values and updates for all this item's metadata fields - */ - updates$: Observable; - /** - * The current url of this page - */ - url: string; - /** - * The time span for being able to undo discarding changes - */ - private discardTimeOut: number; - /** - * Prefix for this component's notification translate keys - */ - private notificationsPrefix = 'item.edit.metadata.notifications.'; +export class ItemMetadataComponent extends AbstractItemUpdateComponent { /** * Observable with a list of strings with all existing metadata field keys @@ -58,44 +36,38 @@ export class ItemMetadataComponent implements OnInit { metadataFields$: Observable; constructor( - private itemService: ItemDataService, - private objectUpdatesService: ObjectUpdatesService, - private router: Router, - private notificationsService: NotificationsService, - private translateService: TranslateService, + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - private route: ActivatedRoute, - private metadataFieldService: RegistryService, + protected route: ActivatedRoute, + protected metadataFieldService: RegistryService, ) { - + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } /** * Set up and initialize all fields */ ngOnInit(): void { + super.ngOnInit(); this.metadataFields$ = this.findMetadataFields(); - this.route.parent.data.pipe(map((data) => data.item)) - .pipe( - first(), - map((data: RemoteData) => data.payload) - ).subscribe((item: Item) => { - this.item = item; - }); + } - this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; - this.url = this.router.url; - if (this.url.indexOf('?') > 0) { - this.url = this.url.substr(0, this.url.indexOf('?')); - } - this.hasChanges().pipe(first()).subscribe((hasChanges) => { - if (!hasChanges) { - this.initializeOriginalFields(); - } else { - this.checkLastModified(); - } - }); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + /** + * Initialize the values and updates of the current item's metadata fields + */ + public initializeUpdates(): void { + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships()); + } + + /** + * Initialize the prefix for notification messages + */ + public initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.metadata.notifications.'; } /** @@ -104,47 +76,23 @@ export class ItemMetadataComponent implements OnInit { */ add(metadata: MetadatumViewModel = new MetadatumViewModel()) { this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); - - } - - /** - * Request the object updates service to discard all current changes to this item - * Shows a notification to remind the user that they can undo this - */ - discard() { - const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); - this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); - } - - /** - * Request the object updates service to undo discarding all changes to this item - */ - reinstate() { - this.objectUpdatesService.reinstateFieldUpdates(this.url); } /** * Sends all initial values of this item to the object updates service */ - private initializeOriginalFields() { - this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); - } - - /** - * Prevent unnecessary rerendering so fields don't lose focus - */ - trackUpdate(index, update: FieldUpdate) { - return update && update.field ? update.field.uuid : undefined; + public initializeOriginalFields() { + this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified); } /** * Requests all current metadata for this item and requests the item service to update the item * Makes sure the new version of the item is rendered on the page */ - submit() { + public submit() { this.isValid().pipe(first()).subscribe((isValid) => { if (isValid) { - const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable; + const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.getMetadataAsListExcludingRelationships()) as Observable; metadata$.pipe( first(), switchMap((metadata: MetadatumViewModel[]) => { @@ -157,7 +105,7 @@ export class ItemMetadataComponent implements OnInit { (rd: RemoteData) => { this.item = rd.payload; this.initializeOriginalFields(); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships()); this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); } ) @@ -167,60 +115,6 @@ export class ItemMetadataComponent implements OnInit { }); } - /** - * Checks whether or not there are currently updates for this item - */ - hasChanges(): Observable { - return this.objectUpdatesService.hasUpdates(this.url); - } - - /** - * Checks whether or not the item is currently reinstatable - */ - isReinstatable(): Observable { - return this.objectUpdatesService.isReinstatable(this.url); - } - - /** - * Checks if the current item is still in sync with the version in the store - * If it's not, a notification is shown and the changes are removed - */ - private checkLastModified() { - const currentVersion = this.item.lastModified; - this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( - (updateVersion: Date) => { - if (updateVersion.getDate() !== currentVersion.getDate()) { - this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); - this.initializeOriginalFields(); - } - } - ); - } - - /** - * Check if the current page is entirely valid - */ - private isValid() { - return this.objectUpdatesService.isValidPage(this.url); - } - - /** - * Get translated notification title - * @param key - */ - private getNotificationTitle(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.title'); - } - - /** - * Get translated notification content - * @param key - */ - private getNotificationContent(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.content'); - - } - /** * Method to request all metadata fields and convert them to a list of strings */ @@ -230,4 +124,8 @@ export class ItemMetadataComponent implements OnInit { take(1), map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); } + + getMetadataAsListExcludingRelationships(): MetadatumViewModel[] { + return this.item.metadataAsList.filter((metadata: MetadatumViewModel) => !metadata.key.startsWith('relation.') && !metadata.key.startsWith('relationship.')); + } } diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html new file mode 100644 index 0000000000..ba5164e81a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -0,0 +1,15 @@ + +
+
{{getRelationshipMessageKey(relationshipLabel) | translate}}
+ +
+
+ +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss new file mode 100644 index 0000000000..ec6cd2ba78 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss @@ -0,0 +1,12 @@ +@import '../../../../../styles/variables.scss'; + +.relationship-row:not(.alert-danger) { + padding: $alert-padding-y 0; +} + +.relationship-row.alert-danger { + margin-left: -$alert-padding-x; + margin-right: -$alert-padding-x; + margin-top: -1px; + margin-bottom: -1px; +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts new file mode 100644 index 0000000000..3748ebca9d --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -0,0 +1,136 @@ +import { EditRelationshipListComponent } from './edit-relationship-list.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../../core/shared/resource-type'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { SharedModule } from '../../../../shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +let comp: EditRelationshipListComponent; +let fixture: ComponentFixture; +let de: DebugElement; + +let objectUpdatesService; +let relationshipService; + +const url = 'http://test-url.com/test-url'; + +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +describe('EditRelationshipListComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftLabel: 'isAuthorOfPublication', + rightLabel: 'isPublicationOfAuthor' + }); + + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + leftId: 'author1', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + leftId: 'author2', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }) + ]; + + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdatesExclusive: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }) + } + ); + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getRelatedItemsByLabel: observableOf([author1, author2]), + } + ); + + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + declarations: [EditRelationshipListComponent], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: RelationshipService, useValue: relationshipService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditRelationshipListComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + comp.item = item; + comp.url = url; + comp.relationshipLabel = relationshipType.leftLabel; + fixture.detectChanges(); + }); + + describe('changeType is REMOVE', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class alert-danger', () => { + const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; + expect(element.classList).toContain('alert-danger'); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts new file mode 100644 index 0000000000..765d6484d4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -0,0 +1,99 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { FieldUpdate, FieldUpdates } from '../../../../core/data/object-updates/object-updates.reducer'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { Item } from '../../../../core/shared/item.model'; +import { switchMap } from 'rxjs/operators'; +import { hasValue } from '../../../../shared/empty.util'; + +@Component({ + selector: 'ds-edit-relationship-list', + styleUrls: ['./edit-relationship-list.component.scss'], + templateUrl: './edit-relationship-list.component.html', +}) +/** + * A component creating a list of editable relationships of a certain type + * The relationships are rendered as a list of related items + */ +export class EditRelationshipListComponent implements OnInit, OnChanges { + /** + * The item to display related items for + */ + @Input() item: Item; + + /** + * The URL to the current page + * Used to fetch updates for the current item from the store + */ + @Input() url: string; + + /** + * The label of the relationship-type we're rendering a list for + */ + @Input() relationshipLabel: string; + + /** + * The FieldUpdates for the relationships in question + */ + updates$: Observable; + + constructor( + protected objectUpdatesService: ObjectUpdatesService, + protected relationshipService: RelationshipService + ) { + } + + ngOnInit(): void { + this.initUpdates(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.initUpdates(); + } + + /** + * Initialize the FieldUpdates using the related items + */ + initUpdates() { + this.updates$ = this.getUpdatesByLabel(this.relationshipLabel); + } + + /** + * Transform the item's relationships of a specific type into related items + * @param label The relationship type's label + */ + public getRelatedItemsByLabel(label: string): Observable { + return this.relationshipService.getRelatedItemsByLabel(this.item, label); + } + + /** + * Get FieldUpdates for the relationships of a specific type + * @param label The relationship type's label + */ + public getUpdatesByLabel(label: string): Observable { + return this.getRelatedItemsByLabel(label).pipe( + switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items)) + ) + } + + /** + * Get the i18n message key for a relationship + * @param label The relationship type's label + */ + public getRelationshipMessageKey(label: string): string { + if (hasValue(label) && label.indexOf('Of') > -1) { + return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` + } else { + return label; + } + } + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html new file mode 100644 index 0000000000..e25c3c204f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+
+ + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss new file mode 100644 index 0000000000..808a8344ba --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss @@ -0,0 +1,15 @@ +@import '../../../../../styles/variables.scss'; + +.btn[disabled] { + color: $gray-600; + border-color: $gray-600; + z-index: 0; // prevent border colors jumping on hover +} + +.relationship-action-buttons { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts new file mode 100644 index 0000000000..3306d8eb01 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts @@ -0,0 +1,179 @@ +import { async, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EditRelationshipComponent } from './edit-relationship.component'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../../core/shared/resource-type'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; + +let objectUpdatesService: ObjectUpdatesService; +const url = 'http://test-url.com/test-url'; + +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +let fixture; +let comp: EditRelationshipComponent; +let de; +let el; + +describe('EditRelationshipComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftLabel: 'isAuthorOfPublication', + rightLabel: 'isPublicationOfAuthor' + }); + + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + leftId: 'author1', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + leftId: 'author2', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }) + ]; + + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + saveChangeFieldUpdate: {}, + saveRemoveFieldUpdate: {}, + setEditableFieldUpdate: {}, + setValidFieldUpdate: {}, + removeSingleFieldUpdate: {}, + isEditable: observableOf(false), // should always return something --> its in ngOnInit + isValid: observableOf(true) // should always return something --> its in ngOnInit + } + ); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [EditRelationshipComponent], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditRelationshipComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + el = de.nativeElement; + + comp.url = url; + comp.fieldUpdate = fieldUpdate1; + comp.item = item; + + fixture.detectChanges(); + }); + + describe('when fieldUpdate has no changeType', () => { + beforeEach(() => { + comp.fieldUpdate = fieldUpdate1; + fixture.detectChanges(); + }); + + describe('canRemove', () => { + it('should return true', () => { + expect(comp.canRemove()).toBe(true); + }); + }); + + describe('canUndo', () => { + it('should return false', () => { + expect(comp.canUndo()).toBe(false); + }); + }); + }); + + describe('when fieldUpdate has DELETE as changeType', () => { + beforeEach(() => { + comp.fieldUpdate = fieldUpdate2; + fixture.detectChanges(); + }); + + describe('canRemove', () => { + it('should return false', () => { + expect(comp.canRemove()).toBe(false); + }); + }); + + describe('canUndo', () => { + it('should return true', () => { + expect(comp.canUndo()).toBe(true); + }); + }); + }); + + describe('remove', () => { + beforeEach(() => { + comp.remove(); + }); + + it('should call saveRemoveFieldUpdate with the correct arguments', () => { + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item); + }); + }); + + describe('undo', () => { + beforeEach(() => { + comp.undo(); + }); + + it('should call removeSingleFieldUpdate with the correct arguments', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.uuid); + }); + }); + +}); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts new file mode 100644 index 0000000000..497b06775a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -0,0 +1,74 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { cloneDeep } from 'lodash'; +import { Item } from '../../../../core/shared/item.model'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { ItemViewMode } from '../../../../shared/items/item-type-decorator'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[ds-edit-relationship]', + styleUrls: ['./edit-relationship.component.scss'], + templateUrl: './edit-relationship.component.html', +}) +export class EditRelationshipComponent implements OnChanges { + /** + * The current field, value and state of the relationship + */ + @Input() fieldUpdate: FieldUpdate; + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * The related item of this relationship + */ + item: Item; + + /** + * The view-mode we're currently on + */ + viewMode = ItemViewMode.Element; + + constructor(private objectUpdatesService: ObjectUpdatesService) { + } + + /** + * Sets the current relationship based on the fieldUpdate input field + */ + ngOnChanges(): void { + this.item = cloneDeep(this.fieldUpdate.field) as Item; + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove(): void { + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item); + } + + /** + * Cancels the current update for this field in the object updates service + */ + undo(): void { + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid); + } + + /** + * Check if a user should be allowed to remove this field + */ + canRemove(): boolean { + return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + + /** + * Check if a user should be allowed to cancel the update to this field + */ + canUndo(): boolean { + return this.fieldUpdate.changeType >= 0; + } + +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html new file mode 100644 index 0000000000..4bd0b3df2c --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -0,0 +1,43 @@ +
+
+ + + +
+
+ +
+
+
+ + + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss new file mode 100644 index 0000000000..898533a9f0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/variables.scss'; + +.button-row { + .btn { + margin-right: 0.5 * $spacer; + + &:last-child { + margin-right: 0; + } + + @media screen and (min-width: map-get($grid-breakpoints, sm)) { + min-width: $edit-item-button-min-width; + } + } + + &.top .btn { + margin-top: $spacer/2; + margin-bottom: $spacer/2; + } + + +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts new file mode 100644 index 0000000000..b1a4e11371 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -0,0 +1,233 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemRelationshipsComponent } from './item-relationships.component'; +import { ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { TestScheduler } from 'rxjs/testing'; +import { SharedModule } from '../../../shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { RelationshipService } from '../../../core/data/relationship.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../../core/cache/response.models'; +import { RequestService } from '../../../core/data/request.service'; + +let comp: any; +let fixture: ComponentFixture; +let de: DebugElement; +let el: HTMLElement; +let objectUpdatesService; +let relationshipService; +let requestService; +let objectCache; +const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); +const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); +const notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } +); +const router = new RouterStub(); +let routeStub; +let itemService; + +const url = 'http://test-url.com/test-url'; +router.url = url; + +let scheduler: TestScheduler; +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +describe('ItemRelationshipsComponent', () => { + beforeEach(async(() => { + const date = new Date(); + + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftLabel: 'isAuthorOfPublication', + rightLabel: 'isPublicationOfAuthor' + }); + + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }) + ]; + + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))), + lastModified: date + }); + + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + relationships[0].leftItem = observableOf(new RemoteData(false, false, true, undefined, author1)); + relationships[0].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); + relationships[1].leftItem = observableOf(new RemoteData(false, false, true, undefined, author2)); + relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + itemService = jasmine.createSpyObj('itemService', { + findById: observableOf(new RemoteData(false, false, true, undefined, item)) + }); + routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, item) }) + } + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }), + getFieldUpdatesExclusive: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([author1, author2]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) + } + ); + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getItemRelationshipLabels: observableOf(['isAuthorOfPublication']), + getRelatedItems: observableOf([author1, author2]), + getRelatedItemsByLabel: observableOf([author1, author2]), + getItemRelationshipsArray: observableOf(relationships), + deleteRelationship: observableOf(new RestResponse(true, 200, 'OK')), + getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships)) + } + ); + + requestService = jasmine.createSpyObj('requestService', + { + removeByHrefSubstring: {}, + hasByHrefObservable: observableOf(false) + } + ); + + objectCache = jasmine.createSpyObj('objectCache', { + remove: undefined + }); + + scheduler = getTestScheduler(); + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + declarations: [ItemRelationshipsComponent], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, + { provide: RelationshipService, useValue: relationshipService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + ChangeDetectorRef + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemRelationshipsComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + el = de.nativeElement; + comp.url = url; + fixture.detectChanges(); + }); + + describe('discard', () => { + beforeEach(() => { + comp.discard(); + }); + + it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => { + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); + }); + }); + + describe('reinstate', () => { + beforeEach(() => { + comp.reinstate(); + }); + + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => { + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); + }); + }); + + describe('submit', () => { + beforeEach(() => { + comp.submit(); + }); + + it('it should delete the correct relationship', () => { + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts new file mode 100644 index 0000000000..e8f34bc70e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -0,0 +1,172 @@ +import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Observable } from 'rxjs/internal/Observable'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { RelationshipService } from '../../../core/data/relationship.service'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { isNotEmptyOperator } from '../../../shared/empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { RequestService } from '../../../core/data/request.service'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { getRelationsByRelatedItemIds } from '../../simple/item-types/shared/item-relationships-utils'; + +@Component({ + selector: 'ds-item-relationships', + styleUrls: ['./item-relationships.component.scss'], + templateUrl: './item-relationships.component.html', +}) +/** + * Component for displaying an item's relationships edit page + */ +export class ItemRelationshipsComponent extends AbstractItemUpdateComponent implements OnDestroy { + + /** + * The labels of all different relations within this item + */ + relationLabels$: Observable; + + /** + * A subscription that checks when the item is deleted in cache and reloads the item by sending a new request + * This is used to update the item in cache after relationships are deleted + */ + itemUpdateSubscription: Subscription; + + constructor( + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected route: ActivatedRoute, + protected relationshipService: RelationshipService, + protected objectCache: ObjectCacheService, + protected requestService: RequestService, + protected cdRef: ChangeDetectorRef + ) { + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); + } + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + super.ngOnInit(); + this.relationLabels$ = this.relationshipService.getItemRelationshipLabels(this.item); + this.initializeItemUpdate(); + } + + /** + * Update the item (and view) when it's removed in the request cache + */ + public initializeItemUpdate(): void { + this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe( + filter((exists: boolean) => !exists), + switchMap(() => this.itemService.findById(this.item.uuid)), + getSucceededRemoteData(), + ).subscribe((itemRD: RemoteData) => { + this.item = itemRD.payload; + this.cdRef.detectChanges(); + }); + } + + /** + * Initialize the values and updates of the current item's relationship fields + */ + public initializeUpdates(): void { + this.updates$ = this.relationshipService.getRelatedItems(this.item).pipe( + switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdates(this.url, items)) + ); + } + + /** + * Initialize the prefix for notification messages + */ + public initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.relationships.notifications.'; + } + + /** + * Resolve the currently selected related items back to relationships and send a delete request for each of the relationships found + * Make sure the lists are refreshed afterwards and notifications are sent for success and errors + */ + public submit(): void { + // Get all IDs of related items of which their relationship with the current item is about to be removed + const removedItemIds$ = this.relationshipService.getRelatedItems(this.item).pipe( + switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items) as Observable), + map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), + map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field.uuid) as string[]), + isNotEmptyOperator() + ); + // Get all the relationships that should be removed + const removedRelationships$ = removedItemIds$.pipe( + getRelationsByRelatedItemIds(this.item, this.relationshipService) + ); + // Request a delete for every relationship found in the observable created above + removedRelationships$.pipe( + take(1), + map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)), + switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid)))) + ).subscribe((responses: RestResponse[]) => { + this.displayNotifications(responses); + this.reset(); + }); + } + + /** + * Display notifications + * - Error notification for each failed response with their message + * - Success notification in case there's at least one successful response + * @param responses + */ + displayNotifications(responses: RestResponse[]) { + const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful); + const successfulResponses = responses.filter((response: RestResponse) => response.isSuccessful); + + failedResponses.forEach((response: ErrorResponse) => { + this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage); + }); + if (successfulResponses.length > 0) { + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + } + } + + /** + * Re-initialize fields and subscriptions + */ + reset() { + this.initializeOriginalFields(); + this.initializeUpdates(); + this.initializeItemUpdate(); + } + + /** + * Sends all initial values of this item to the object updates service + */ + public initializeOriginalFields() { + this.relationshipService.getRelatedItems(this.item).pipe(take(1)).subscribe((items: Item[]) => { + this.objectUpdatesService.initialize(this.url, items, this.item.lastModified); + }); + } + + /** + * Unsubscribe from the item update when the component is destroyed + */ + ngOnDestroy(): void { + this.itemUpdateSubscription.unsubscribe(); + } + +} diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 6743028b6c..f510ccf19b 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -62,7 +62,8 @@ import { MetadataFieldWrapperComponent } from './field-components/metadata-field GenericItemPageFieldComponent, RelatedEntitiesSearchComponent, RelatedItemsComponent, - MetadataRepresentationListComponent + MetadataRepresentationListComponent, + ItemPageTitleFieldComponent ], entryComponents: [ PublicationComponent 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 65ad743245..b4eda2abfb 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 @@ -7,10 +7,12 @@ import { hasNoValue, 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, switchMap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, flatMap, map, switchMap, tap } from 'rxjs/operators'; 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'; +import { RelationshipService } from '../../../../core/data/relationship.service'; /** * Operator for comparing arrays using a mapping function @@ -147,3 +149,17 @@ export const relationsToRepresentations = (parentId: string, itemType: string, m ) ) ); + +/** + * Operator for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup) + * Only relationships where leftItem or rightItem's ID is present in the list provided will be returned + * @param item + * @param relationshipService + */ +export const getRelationsByRelatedItemIds = (item: Item, relationshipService: RelationshipService) => + (source: Observable): Observable => + source.pipe( + flatMap((relatedItemIds: string[]) => relationshipService.getItemResolvedRelatedItemsAndRelationships(item).pipe( + map(([leftItems, rightItems, rels]) => rels.filter((rel: Relationship, index: number) => relatedItemIds.indexOf(leftItems[index].uuid) > -1 || relatedItemIds.indexOf(rightItems[index].uuid) > -1)) + )) + ); diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.ts index 705ec897f8..39c7574407 100644 --- a/src/app/+my-dspace-page/my-dspace-configuration.service.ts +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.ts @@ -8,7 +8,7 @@ import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value- 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 { RouteService } from '../core/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'; 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 index d9b5facb5b..27daa30a0f 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts @@ -17,7 +17,7 @@ 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 { RouteService } from '../core/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'; diff --git a/src/app/+search-page/configuration-search-page.component.ts b/src/app/+search-page/configuration-search-page.component.ts index 85619e8f04..b1a94fc086 100644 --- a/src/app/+search-page/configuration-search-page.component.ts +++ b/src/app/+search-page/configuration-search-page.component.ts @@ -4,12 +4,12 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchPageComponent } from './search-page.component'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } 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'; import { map } from 'rxjs/operators'; +import { RouteService } from '../core/services/route.service'; /** * This component renders a search page using a configuration as input. diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts index 66c619b823..0bcc9e14e3 100644 --- a/src/app/+search-page/filtered-search-page.component.ts +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -4,12 +4,12 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchPageComponent } from './search-page.component'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } 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'; import { map } from 'rxjs/operators'; +import { RouteService } from '../core/services/route.service'; /** * This component renders a simple item page. 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 index 76cdc6c8f5..63d034c6ea 100644 --- 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 @@ -17,7 +17,7 @@ {{filterValue.value}} 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 1fccee3736..eae9a8cc7f 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 @@ -50,6 +50,10 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { */ addQueryParams; + /** + * Link to the search page + */ + searchLink: string; /** * Subscription to unsubscribe from on destroy */ @@ -66,6 +70,7 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { * Initializes all observable instance variables and starts listening to them */ ngOnInit(): void { + this.searchLink = this.getSearchLink(); this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions) .subscribe(([selectedValues, searchOptions]) => { @@ -83,7 +88,7 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } 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 4efee3e7b5..798038503f 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,5 +1,5 @@ {{filterValue.label}} 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 77f240a899..39b24b36fd 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 @@ -56,6 +56,11 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { */ sub: Subscription; + /** + * Link to the search page + */ + searchLink: string; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, @@ -67,6 +72,7 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { * Initializes all observable instance variables and starts listening to them */ ngOnInit(): void { + this.searchLink = this.getSearchLink(); this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); this.sub = this.searchConfigService.searchOptions.subscribe(() => { this.updateChangeParams() @@ -83,7 +89,7 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } 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 5657bd224e..5198433207 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 @@ -1,5 +1,5 @@ {{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.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 78dde92c2b..3f84b7c4e5 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 @@ -49,6 +49,11 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { */ sub: Subscription; + /** + * Link to the search page + */ + searchLink: string; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, @@ -64,12 +69,13 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { .subscribe(([selectedValues, searchOptions]) => { this.updateRemoveParams(selectedValues) }); + this.searchLink = this.getSearchLink(); } /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } 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 ee980a0599..ccbda54f89 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 @@ -80,6 +80,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ searchOptions$: Observable; + /** + * The current URL + */ + currentUrl: string; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected rdbs: RemoteDataBuildService, @@ -93,6 +98,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * Initializes all observable instance variables and starts listening to them */ ngOnInit(): void { + this.currentUrl = this.router.url; this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined)); this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged()); @@ -215,13 +221,6 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { return this.filterService.getPage(this.filterConfig.name); } - /** - * @returns {string} the current URL - */ - getCurrentUrl() { - return this.router.url; - } - /** * Submits a new active custom value to the filter from the input field * @param data The string from the input field 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 68aca01a9e..a453dc29bf 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -1,5 +1,5 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators'; +import { distinctUntilChanged, map, mergeMap } from 'rxjs/operators'; import { Injectable, InjectionToken } from '@angular/core'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; @@ -14,16 +14,12 @@ import { } from './search-filter.actions'; import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; -import { RouteService } from '../../../shared/services/route.service'; +import { RouteService } from '../../../core/services/route.service'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SearchOptions } from '../../search-options.model'; -import { PaginatedSearchOptions } from '../../paginated-search-options.model'; import { SearchFixedFilterService } from './search-fixed-filter.service'; import { Params } from '@angular/router'; -import * as postcss from 'postcss'; -import prefix = postcss.vendor.prefix; -// const spy = create(); + const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts index 1ff52e89a4..591e26c8cc 100644 --- a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts @@ -1,7 +1,5 @@ import { SearchFixedFilterService } from './search-fixed-filter.service'; -import { RouteService } from '../../../shared/services/route.service'; import { RequestService } from '../../../core/data/request.service'; -import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; import { of as observableOf } from 'rxjs'; import { RequestEntry } from '../../../core/data/request.reducer'; import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models'; @@ -11,7 +9,6 @@ describe('SearchFixedFilterService', () => { const filterQuery = 'filter:query'; - const routeServiceStub = {} as RouteService; const requestServiceStub = Object.assign({ /* tslint:disable:no-empty */ configure: () => { @@ -22,9 +19,6 @@ describe('SearchFixedFilterService', () => { response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK') })) }) as RequestService; - const halServiceStub = Object.assign(new HALEndpointService(requestServiceStub, undefined), { - getEndpoint: () => observableOf('fake-url') - }); beforeEach(() => { service = new SearchFixedFilterService(); 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 ac2a72f4b6..996fd7f751 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 @@ -17,7 +17,7 @@
+ [action]="currentUrl">
-
{{"search.filters.reset" | translate}} +{{"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 dc883cd290..030702d93e 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 @@ -58,7 +58,7 @@ describe('SearchFiltersComponent', () => { describe('when the getSearchLink method is called', () => { beforeEach(() => { spyOn(searchService, 'getSearchLink'); - comp.getSearchLink(); + (comp as any).getSearchLink(); }); it('should call getSearchLink on the searchService', () => { 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 e970647747..9d0dfceb15 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -37,6 +37,11 @@ export class SearchFiltersComponent implements OnInit { */ @Input() inPlaceSearch; + /** + * Link to the search page + */ + searchLink: string; + /** * Initialize instance variables * @param {SearchService} searchService @@ -60,12 +65,13 @@ export class SearchFiltersComponent implements OnInit { Object.keys(filters).forEach((f) => filters[f] = null); return filters; })); + this.searchLink = this.getSearchLink(); } /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } diff --git a/src/app/+search-page/search-labels/search-label/search-label.component.html b/src/app/+search-page/search-labels/search-label/search-label.component.html new file mode 100644 index 0000000000..391efcb763 --- /dev/null +++ b/src/app/+search-page/search-labels/search-label/search-label.component.html @@ -0,0 +1,6 @@ + + {{('search.filters.applied.' + key) | translate}}: {{normalizeFilterValue(value)}} + × + \ No newline at end of file diff --git a/src/app/+search-page/search-labels/search-label/search-label.component.spec.ts b/src/app/+search-page/search-labels/search-label/search-label.component.spec.ts new file mode 100644 index 0000000000..a2603b7b8b --- /dev/null +++ b/src/app/+search-page/search-labels/search-label/search-label.component.spec.ts @@ -0,0 +1,87 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Observable, of as observableOf } from 'rxjs'; +import { Params } from '@angular/router'; +import { SearchLabelComponent } from './search-label.component'; +import { ObjectKeysPipe } from '../../../shared/utils/object-keys-pipe'; +import { SearchService } from '../../search-service/search.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; +import { SearchServiceStub } from '../../../shared/testing/search-service-stub'; +import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service-stub'; + +describe('SearchLabelComponent', () => { + let comp: SearchLabelComponent; + let fixture: ComponentFixture; + + const searchLink = '/search'; + let searchService; + + const key1 = 'author'; + const key2 = 'subject'; + const value1 = 'Test, Author'; + const normValue1 = 'Test, Author'; + const value2 = 'TestSubject'; + const value3 = 'Test, Authority,authority'; + const normValue3 = 'Test, Authority'; + const filter1 = [key1, value1]; + const filter2 = [key2, value2]; + const mockFilters = [ + filter1, + filter2 + ]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchLabelComponent, ObjectKeysPipe], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } + // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchLabelComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchLabelComponent); + comp = fixture.componentInstance; + searchService = (comp as any).searchService; + comp.key = key1; + comp.value = value1; + (comp as any).appliedFilters = observableOf(mockFilters); + fixture.detectChanges(); + }); + + describe('when getRemoveParams is called', () => { + let obs: Observable; + + beforeEach(() => { + obs = comp.getRemoveParams(); + }); + + it('should return all params but the provided filter', () => { + obs.subscribe((params) => { + // Should contain only filter2 and page: length == 2 + expect(Object.keys(params).length).toBe(2); + }); + }) + }); + + 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-label/search-label.component.ts b/src/app/+search-page/search-labels/search-label/search-label.component.ts new file mode 100644 index 0000000000..2f44f91a35 --- /dev/null +++ b/src/app/+search-page/search-labels/search-label/search-label.component.ts @@ -0,0 +1,75 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Params } from '@angular/router'; +import { SearchService } from '../../search-service/search.service'; +import { map } from 'rxjs/operators'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; + +@Component({ + selector: 'ds-search-label', + templateUrl: './search-label.component.html', +}) + +/** + * Component that represents the label containing the currently active filters + */ +export class SearchLabelComponent implements OnInit { + @Input() key: string; + @Input() value: string; + @Input() inPlaceSearch: boolean; + @Input() appliedFilters: Observable; + searchLink: string; + removeParameters: Observable; + + /** + * Initialize the instance variable + */ + constructor( + private searchService: SearchService) { + } + + ngOnInit(): void { + this.searchLink = this.getSearchLink(); + this.removeParameters = this.getRemoveParams(); + } + + /** + * Calculates the parameters that should change if a given value for the given filter would be removed from the active filters + * @returns {Observable} The changed filter parameters + */ + getRemoveParams(): Observable { + return this.appliedFilters.pipe( + map((filters) => { + const field: string = Object.keys(filters).find((f) => f === this.key); + const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== this.value) : null; + return { + [field]: isNotEmpty(newValues) ? newValues : null, + page: 1 + }; + }) + ) + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + private 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-labels/search-labels.component.html b/src/app/+search-page/search-labels/search-labels.component.html index cac81e8717..6a668826da 100644 --- a/src/app/+search-page/search-labels/search-labels.component.html +++ b/src/app/+search-page/search-labels/search-labels.component.html @@ -1,13 +1,7 @@ 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 d28698764c..7984805206 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 @@ -6,11 +6,9 @@ import { SearchService } from '../search-service/search.service'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { SearchServiceStub } from '../../shared/testing/search-service-stub'; -import { Observable, of as observableOf } from 'rxjs'; -import { Params } from '@angular/router'; +import { of as observableOf } from 'rxjs'; import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe'; 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; @@ -22,10 +20,7 @@ describe('SearchLabelsComponent', () => { const field1 = 'author'; const field2 = 'subject'; 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 = [ @@ -39,8 +34,7 @@ describe('SearchLabelsComponent', () => { declarations: [SearchLabelsComponent, ObjectKeysPipe], providers: [ { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, - { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } - // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } + { provide: SEARCH_CONFIG_SERVICE, useValue: { getCurrentFrontendFilters: () => observableOf(mockFilters) } } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchLabelsComponent, { @@ -56,30 +50,11 @@ describe('SearchLabelsComponent', () => { fixture.detectChanges(); }); - describe('when getRemoveParams is called', () => { - let obs: Observable; - - beforeEach(() => { - obs = comp.getRemoveParams(filter1[0], filter1[1]); - }); - + describe('when the component has been initialized', () => { it('should return all params but the provided filter', () => { - obs.subscribe((params) => { - // Should contain only filter2 and page: length == 2 - expect(Object.keys(params).length).toBe(2); + comp.appliedFilters.subscribe((filters) => { + expect(filters).toBe(mockFilters); }); }) }); - - 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 104ed5b08b..5f95525bed 100644 --- a/src/app/+search-page/search-labels/search-labels.component.ts +++ b/src/app/+search-page/search-labels/search-labels.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { Observable } from 'rxjs'; import { Params } from '@angular/router'; @@ -31,50 +31,7 @@ export class SearchLabelsComponent { * Initialize the instance variable */ constructor( - private searchService: SearchService, @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters(); } - - /** - * Calculates the parameters that should change if a given value for the given filter would be removed from the active filters - * @param {string} filterField The filter field parameter name from which the value should be removed - * @param {string} filterValue The value that is removed for this given filter field - * @returns {Observable} The changed filter parameters - */ - getRemoveParams(filterField: string, filterValue: string): Observable { - return this.appliedFilters.pipe( - map((filters) => { - const field: string = Object.keys(filters).find((f) => f === filterField); - const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== filterValue) : null; - return { - [field]: isNotEmpty(newValues) ? newValues : null, - page: 1 - }; - }) - ) - } - - /** - * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true - */ - public getSearchLink(): string { - if (this.inPlaceSearch) { - return './'; - } - return this.searchService.getSearchLink(); - } - - /** - * 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-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index d1ab02945e..d2c3b3be39 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -13,4 +13,5 @@ import { ConfigurationSearchPageComponent } from './configuration-search-page.co ]) ] }) -export class SearchPageRoutingModule { } +export class SearchPageRoutingModule { +} diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index ea04a2b04e..85ad8286bf 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -7,7 +7,7 @@ @@ -15,12 +15,12 @@
+ [@pushInOut]="(isSidebarCollapsed$ | async) ? 'collapsed' : 'expanded'"> + [ngClass]="{'active': !(isSidebarCollapsed$ | async)}">
diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index 1b26e70ba9..d072c80628 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -21,7 +21,7 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte import { SearchConfigurationService } from './search-service/search-configuration.service'; import { RemoteData } from '../core/data/remote-data'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from '../core/services/route.service'; import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; import { PaginatedSearchOptions } from './paginated-search-options.model'; import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; @@ -197,7 +197,7 @@ describe('SearchPageComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; - comp.isSidebarCollapsed = () => observableOf(true); + (comp as any).isSidebarCollapsed$ = observableOf(true); fixture.detectChanges(); }); @@ -212,7 +212,7 @@ describe('SearchPageComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; - comp.isSidebarCollapsed = () => observableOf(false); + (comp as any).isSidebarCollapsed$ = observableOf(false); fixture.detectChanges(); }); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 03433d1da1..0558065142 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -13,7 +13,7 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { getSucceededRemoteData } from '../core/shared/operators'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from '../core/services/route.service'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; export const SEARCH_ROUTE = '/search'; @@ -91,6 +91,16 @@ export class SearchPageComponent implements OnInit { @Input() configuration$: Observable; + /** + * Link to the search page + */ + searchLink: string; + + /** + * Observable for whether or not the sidebar is currently collapsed + */ + isSidebarCollapsed$: Observable; + constructor(protected service: SearchService, protected sidebarService: SearchSidebarService, protected windowService: HostWindowService, @@ -107,9 +117,11 @@ export class SearchPageComponent implements OnInit { * If something changes, update the list of scopes for the dropdown */ ngOnInit(): void { + this.isSidebarCollapsed$ = this.isSidebarCollapsed(); + this.searchLink = this.getSearchLink(); this.searchOptions$ = this.getSearchOptions(); this.sub = this.searchOptions$.pipe( - switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(observableOf(undefined))))) + switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(undefined)))) .subscribe((results) => { this.resultsRD$.next(results); }); @@ -147,14 +159,14 @@ export class SearchPageComponent implements OnInit { * Check if the sidebar is collapsed * @returns {Observable} emits true if the sidebar is currently collapsed, false if it is expanded */ - public isSidebarCollapsed(): Observable { + private isSidebarCollapsed(): Observable { return this.sidebarService.isCollapsed; } /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index d7d66d854c..816633ec38 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -30,6 +30,7 @@ import { SearchFacetSelectedOptionComponent } from './search-filters/search-filt import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component'; import { SearchAuthorityFilterComponent } from './search-filters/search-filter/search-authority-filter/search-authority-filter.component'; +import { SearchLabelComponent } from './search-labels/search-label/search-label.component'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { FilteredSearchPageComponent } from './filtered-search-page.component'; @@ -50,6 +51,7 @@ const components = [ SearchFilterComponent, SearchFacetFilterComponent, SearchLabelsComponent, + SearchLabelComponent, SearchFacetFilterComponent, SearchFacetFilterWrapperComponent, SearchRangeFilterComponent, diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts index 6258140a4e..5a2343b058 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -14,7 +14,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SearchOptions } from '../search-options.model'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { RouteService } from '../../shared/services/route.service'; +import { RouteService } from '../../core/services/route.service'; import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from '../../core/data/remote-data'; import { getSucceededRemoteData } from '../../core/shared/operators'; diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index 4ae9876159..798670a0e0 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -26,7 +26,7 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { map } from 'rxjs/operators'; -import { RouteService } from '../../shared/services/route.service'; +import { RouteService } from '../../core/services/route.service'; import { routeServiceStub } from '../../shared/testing/route-service-stub'; import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 86611cc87b..e2b24200df 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,7 +1,7 @@ -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; -import { NavigationExtras, Router } from '@angular/router'; -import { first, map, switchMap } from 'rxjs/operators'; +import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; +import { first, map, switchMap, tap } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { FacetConfigSuccessResponse, @@ -23,7 +23,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { NormalizedSearchResult } from '../normalized-search-result.model'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; @@ -41,7 +41,7 @@ import { Community } from '../../core/shared/community.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { RouteService } from '../../shared/services/route.service'; +import { RouteService } from '../../core/services/route.service'; /** * Service that performs all general actions that have to do with the search page @@ -103,11 +103,18 @@ export class SearchService implements OnDestroy { * @returns {Observable>>>} Emits a paginated list with all search results found */ search(searchOptions?: PaginatedSearchOptions): Observable>>> { - const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe( + const hrefObs = this.halService.getEndpoint(this.searchLinkPath).pipe( map((url: string) => { if (hasValue(searchOptions)) { - url = (searchOptions as PaginatedSearchOptions).toRestUrl(url); + return (searchOptions as PaginatedSearchOptions).toRestUrl(url); + } else { + return url; } + }) + ); + + const requestObs = hrefObs.pipe( + map((url: string) => { const request = new this.request(this.requestService.generateRequestId(), url); const getResponseParserFn: () => GenericConstructor = () => { @@ -136,10 +143,11 @@ export class SearchService implements OnDestroy { map((sqr: SearchQueryResponse) => { return sqr.objects .filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject)) - .map((nsr: NormalizedSearchResult) => { - return this.rdb.buildSingle(nsr.indexableObject); - }) + .map((nsr: NormalizedSearchResult) => new GetRequest(this.requestService.generateRequestId(), nsr.indexableObject)) }), + // Send a request for each item to ensure fresh cache + tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))), + map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href))), switchMap((input: Array>>) => this.rdb.aggregate(input)), ); @@ -168,11 +176,20 @@ export class SearchService implements OnDestroy { const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe( map(([tDomainList, pageInfo]) => { - return new PaginatedList(pageInfo, tDomainList); + return new PaginatedList(pageInfo, tDomainList.filter((obj) => hasValue(obj))); }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + return observableCombineLatest(hrefObs, tDomainListObs, requestEntryObs).pipe( + switchMap(([href, tDomainList, requestEntry]) => { + if (tDomainList.indexOf(undefined) > -1 && requestEntry && requestEntry.completed) { + this.requestService.removeByHrefSubstring(href); + return this.search(searchOptions) + } else { + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + } + }) + ); } /** diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 86364aca89..e1ddc2b889 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -16,6 +16,12 @@ const COMMUNITY_MODULE_PATH = 'communities'; export function getCommunityModulePath() { return `/${COMMUNITY_MODULE_PATH}`; } + +const ADMIN_MODULE_PATH = 'admin'; +export function getAdminModulePath() { + return `/${ADMIN_MODULE_PATH}`; +} + @NgModule({ imports: [ RouterModule.forRoot([ @@ -27,7 +33,7 @@ export function getCommunityModulePath() { { 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] }, + { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index bd2d832c67..5b78e3462f 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -26,7 +26,7 @@ import { HostWindowResizeAction } from './shared/host-window.actions'; import { MetadataService } from './core/metadata/metadata.service'; import { GLOBAL_CONFIG, ENV_CONFIG } from '../config'; -import { NativeWindowRef, NativeWindowService } from './shared/services/window.service'; +import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { MockTranslateLoader } from './shared/mocks/mock-translate-loader'; import { MockMetadataService } from './shared/mocks/mock-metadata-service'; @@ -41,9 +41,11 @@ import { MenuServiceStub } from './shared/testing/menu-service-stub'; import { HostWindowService } from './shared/host-window.service'; import { HostWindowServiceStub } from './shared/testing/host-window-service-stub'; import { ActivatedRoute, Router } from '@angular/router'; -import { RouteService } from './shared/services/route.service'; +import { RouteService } from './core/services/route.service'; import { MockActivatedRoute } from './shared/mocks/mock-active-router'; import { MockRouter } from './shared/mocks/mock-router'; +import { MockCookieService } from './shared/mocks/mock-cookie.service'; +import { CookieService } from './core/services/cookie.service'; let comp: AppComponent; let fixture: ComponentFixture; @@ -78,6 +80,7 @@ describe('App component', () => { { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: CookieService, useValue: new MockCookieService()}, AppComponent, RouteService ], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 52c169e7bc..50baaf6e57 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,11 +19,11 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowState } from './shared/host-window.reducer'; -import { NativeWindowRef, NativeWindowService } from './shared/services/window.service'; +import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; -import { RouteService } from './shared/services/route.service'; +import { RouteService } from './core/services/route.service'; import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; @@ -32,6 +32,10 @@ import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { slideSidebarPadding } from './shared/animations/slide'; import { HostWindowService } from './shared/host-window.service'; import { Theme } from '../config/theme.inferface'; +import { isNotEmpty } from './shared/empty.util'; +import { CookieService } from './core/services/cookie.service'; + +export const LANG_COOKIE = 'language_cookie'; @Component({ selector: 'ds-app', @@ -61,6 +65,7 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, + private cookie: CookieService ) { // Load all the languages that are defined as active from the config file translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); @@ -68,11 +73,20 @@ export class AppComponent implements OnInit, AfterViewInit { // Load the default language from the config file translate.setDefaultLang(config.defaultLanguage); - // Attempt to get the browser language from the user - if (translate.getLangs().includes(translate.getBrowserLang())) { - translate.use(translate.getBrowserLang()); + // Attempt to get the language from a cookie + const lang = cookie.get(LANG_COOKIE); + if (isNotEmpty(lang)) { + // Cookie found + // Use the language from the cookie + translate.use(lang); } else { - translate.use(config.defaultLanguage); + // Cookie not found + // Attempt to get the browser language from the user + if (translate.getLangs().includes(translate.getBrowserLang())) { + translate.use(translate.getBrowserLang()); + } else { + translate.use(config.defaultLanguage); + } } metadata.listenForRouteChange(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ce5a2d78a2..916788df8c 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -39,6 +39,7 @@ import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/e import { NavbarModule } from './navbar/navbar.module'; import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module'; +import { ClientCookieService } from './core/services/client-cookie.service'; export function getConfig() { return ENV_CONFIG; @@ -97,7 +98,8 @@ const PROVIDERS = [ { provide: RouterStateSerializer, useClass: DSpaceRouterStateSerializer - } + }, + ClientCookieService ]; const DECLARATIONS = [ diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index ea2512a974..e3333fb34a 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -23,6 +23,10 @@ import { hasValue } from './shared/empty.util'; import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; import { historyReducer, HistoryState } from './shared/history/history.reducer'; +import { + bitstreamFormatReducer, + BitstreamFormatRegistryState +} from './+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; export interface AppState { router: fromRouter.RouterReducerState; @@ -30,6 +34,7 @@ export interface AppState { hostWindow: HostWindowState; forms: FormState; metadataRegistry: MetadataRegistryState; + bitstreamFormats: BitstreamFormatRegistryState; notifications: NotificationsState; searchSidebar: SearchSidebarState; searchFilter: SearchFiltersState; @@ -44,6 +49,7 @@ export const appReducers: ActionReducerMap = { hostWindow: hostWindowReducer, forms: formReducer, metadataRegistry: metadataRegistryReducer, + bitstreamFormats: bitstreamFormatReducer, notifications: notificationsReducer, searchSidebar: sidebarReducer, searchFilter: filterReducer, diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index e766a45e48..ab2e6fd86b 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -7,12 +7,12 @@ import { REQUEST } from '@nguniversal/express-engine/tokens'; import { of as observableOf } from 'rxjs'; import { authReducer, AuthState } from './auth.reducer'; -import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { AuthService } from './auth.service'; import { RouterStub } from '../../shared/testing/router-stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; -import { CookieService } from '../../shared/services/cookie.service'; +import { CookieService } from '../services/cookie.service'; import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub'; import { AuthRequestService } from './auth-request.service'; import { AuthStatus } from './models/auth-status.model'; @@ -20,7 +20,7 @@ import { AuthTokenInfo } from './models/auth-token-info.model'; import { EPerson } from '../eperson/models/eperson.model'; import { EPersonMock } from '../../shared/testing/eperson-mock'; import { AppState } from '../../app.reducer'; -import { ClientCookieService } from '../../shared/services/client-cookie.service'; +import { ClientCookieService } from '../services/client-cookie.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index a01768e687..08c94b02f2 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -15,11 +15,11 @@ 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 { CookieService } from '../services/cookie.service'; 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 { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; diff --git a/src/app/core/cache/models/normalized-bitstream-format.model.ts b/src/app/core/cache/models/normalized-bitstream-format.model.ts index 5ee135b530..2283ecb368 100644 --- a/src/app/core/cache/models/normalized-bitstream-format.model.ts +++ b/src/app/core/cache/models/normalized-bitstream-format.model.ts @@ -4,7 +4,7 @@ import { BitstreamFormat } from '../../shared/bitstream-format.model'; import { mapsTo } from '../builders/build-decorators'; import { IDToUUIDSerializer } from '../id-to-uuid-serializer'; import { NormalizedObject } from './normalized-object.model'; -import { SupportLevel } from './support-level.model'; +import { BitstreamFormatSupportLevel } from '../../shared/bitstream-format-support-level'; /** * Normalized model class for a Bitstream Format @@ -34,7 +34,7 @@ export class NormalizedBitstreamFormat extends NormalizedObject * The level of support the system offers for this Bitstream Format */ @autoserialize - supportLevel: SupportLevel; + supportLevel: BitstreamFormatSupportLevel; /** * True if the Bitstream Format is used to store system information, rather than the content of items in the system @@ -46,7 +46,7 @@ export class NormalizedBitstreamFormat extends NormalizedObject * String representing this Bitstream Format's file extension */ @autoserialize - extensions: string; + extensions: string[]; /** * Identifier for this Bitstream Format diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index e18ff3b3a4..11f3a6ce3e 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, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { CoreState } from '../core.reducers'; import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; @@ -68,8 +68,8 @@ export class ObjectCacheService { * @param href * The unique href of the object to be removed */ - remove(uuid: string): void { - this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); + remove(href: string): void { + this.store.dispatch(new RemoveFromObjectCacheAction(href)); } /** @@ -224,6 +224,18 @@ export class ObjectCacheService { return result; } + /** + * Create an observable that emits a new value whenever the availability of the cached object changes. + * The value it emits is a boolean stating if the object exists in cache or not. + * @param selfLink The self link of the object to observe + */ + hasBySelfLinkObservable(selfLink: string): Observable { + return this.store.pipe( + select(entryFromSelfLinkSelector(selfLink)), + map((entry: ObjectCacheEntry) => this.isValid(entry)) + ); + } + /** * Check whether an ObjectCacheEntry should still be cached * diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 1aa5d2d0b1..0eabfc5dc8 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -5,7 +5,7 @@ import { AuthEffects } from './auth/auth.effects'; import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; -import { RouteEffects } from '../shared/services/route.effects'; +import { RouteEffects } from './services/route.effects'; export const coreEffects = [ RequestEffects, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 1d22b5fefe..f4d4dcb269 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -14,7 +14,7 @@ import { coreReducers } from './core.reducers'; import { isNotEmpty } from '../shared/empty.util'; -import { ApiService } from '../shared/services/api.service'; +import { ApiService } from './services/api.service'; import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service'; import { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; @@ -34,12 +34,12 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; import { RequestService } from './data/request.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; -import { ServerResponseService } from '../shared/services/server-response.service'; -import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service'; +import { ServerResponseService } from './services/server-response.service'; +import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BrowseService } from './browse/browse.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; import { ConfigResponseParsingService } from './config/config-response-parsing.service'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from './services/route.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; @@ -101,12 +101,14 @@ import { NormalizedSubmissionFormsModel } from './config/models/normalized-confi import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model'; import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model'; import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model'; +import { RelationshipService } from './data/relationship.service'; import { RoleService } from './roles/role.service'; import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; import { PoolTaskDataService } from './tasks/pool-task-data.service'; import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; +import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; import { NormalizedClaimedTask } from './tasks/models/normalized-claimed-task-object.model'; import { NormalizedTaskObject } from './tasks/models/normalized-task-object.model'; import { NormalizedPoolTask } from './tasks/models/normalized-pool-task-object.model'; @@ -153,6 +155,7 @@ const PROVIDERS = [ PaginationComponentOptions, ResourcePolicyService, RegistryService, + BitstreamFormatDataService, NormalizedObjectBuildService, RemoteDataBuildService, RequestService, @@ -198,6 +201,7 @@ const PROVIDERS = [ MenuService, ObjectUpdatesService, SearchService, + RelationshipService, MyDSpaceGuard, RoleService, TaskResponseParsingService, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 7aecb91a7a..4fcf36f9cc 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -13,7 +13,7 @@ import { objectUpdatesReducer, ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; -import { routeReducer, RouteState } from '../shared/services/route.reducer'; +import { routeReducer, RouteState } from './services/route.reducer'; export interface CoreState { 'cache/object': ObjectCacheState, diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts new file mode 100644 index 0000000000..f3ce478236 --- /dev/null +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -0,0 +1,293 @@ +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { RequestEntry } from './request.reducer'; +import { RestResponse } from '../cache/response.models'; +import { Observable, of as observableOf } from 'rxjs'; +import { Action, Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { async } from '@angular/core/testing'; +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction +} from '../../+admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { TestScheduler } from 'rxjs/testing'; + +describe('BitstreamFormatDataService', () => { + let service: BitstreamFormatDataService; + let requestService; + let scheduler: TestScheduler; + + const bitstreamFormatsEndpoint = 'https://rest.api/core/bitstream-formats'; + const bitstreamFormatsIdEndpoint = 'https://rest.api/core/bitstream-formats/format-id'; + + const responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + responseCacheEntry.completed = true; + + const store = { + dispatch(action: Action) { + // Do Nothing + } + } as Store; + + const objectCache = {} as ObjectCacheService; + const halEndpointService = { + getEndpoint(linkPath: string): Observable { + return cold('a', {a: bitstreamFormatsEndpoint}); + } + } as HALEndpointService; + + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = {} as NormalizedObjectBuildService; + const rdbService = {} as RemoteDataBuildService; + + function initTestService(halService) { + return new BitstreamFormatDataService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + describe('getBrowseEndpoint', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + })); + it('should get the browse endpoint', () => { + const result = service.getBrowseEndpoint(); + const expected = cold('b', {b: bitstreamFormatsEndpoint}); + + expect(result).toBeObservable(expected); + }); + }); + + describe('getUpdateEndpoint', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + })); + it('should get the update endpoint', () => { + const formatId = 'format-id'; + + const result = service.getUpdateEndpoint(formatId); + const expected = cold('b', {b: bitstreamFormatsIdEndpoint}); + + expect(result).toBeObservable(expected); + }); + }); + + describe('getCreateEndpoint', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + })); + it('should get the create endpoint ', () => { + + const result = service.getCreateEndpoint(); + const expected = cold('b', {b: bitstreamFormatsEndpoint}); + + expect(result).toBeObservable(expected); + }); + }); + + describe('updateBitstreamFormat', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + })); + it('should update the bitstream format', () => { + const updatedBistreamFormat = new BitstreamFormat(); + updatedBistreamFormat.uuid = 'updated-uuid'; + + const expected = cold('(b)', {b: new RestResponse(true, 200, 'Success')}); + const result = service.updateBitstreamFormat(updatedBistreamFormat); + + expect(result).toBeObservable(expected); + + }); + }); + + describe('createBitstreamFormat', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + })); + it('should create a new bitstream format', () => { + const newFormat = new BitstreamFormat(); + newFormat.uuid = 'new-uuid'; + + const expected = cold('(b)', {b: new RestResponse(true, 200, 'Success')}); + const result = service.createBitstreamFormat(newFormat); + + expect(result).toBeObservable(expected); + }); + }); + + describe('clearBitStreamFormatRequests', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + const halService = { + getEndpoint(linkPath: string): Observable { + return observableOf(bitstreamFormatsEndpoint); + } + } as HALEndpointService; + service = initTestService(halService); + service.clearBitStreamFormatRequests().subscribe(); + })); + it('should remove the bitstream format hrefs in the request service', () => { + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(bitstreamFormatsEndpoint); + }); + }); + + describe('selectBitstreamFormat', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + spyOn(store, 'dispatch'); + })); + it('should add a selected bitstream to the store', () => { + const format = new BitstreamFormat(); + format.uuid = 'uuid'; + + service.selectBitstreamFormat(format); + expect(store.dispatch).toHaveBeenCalledWith(new BitstreamFormatsRegistrySelectAction(format)); + }); + }); + + describe('deselectBitstreamFormat', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + spyOn(store, 'dispatch'); + })); + it('should remove a bitstream from the store', () => { + const format = new BitstreamFormat(); + format.uuid = 'uuid'; + + service.deselectBitstreamFormat(format); + expect(store.dispatch).toHaveBeenCalledWith(new BitstreamFormatsRegistryDeselectAction(format)); + }); + }); + + describe('deselectAllBitstreamFormats', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: cold('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + service = initTestService(halEndpointService); + spyOn(store, 'dispatch'); + + })); + it('should remove all bitstreamFormats from the store', () => { + service.deselectAllBitstreamFormats(); + expect(store.dispatch).toHaveBeenCalledWith(new BitstreamFormatsRegistryDeselectAllAction()); + }); + }); + + describe('delete', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + requestService = jasmine.createSpyObj('requestService', { + configure: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: hot('a', {a: responseCacheEntry}), + generateRequestId: 'request-id', + removeByHrefSubstring: {} + }); + const halService = { + getEndpoint(linkPath: string): Observable { + return observableOf(bitstreamFormatsEndpoint); + } + } as HALEndpointService; + service = initTestService(halService); + })); + it('should delete a bitstream format', () => { + const format = new BitstreamFormat(); + format.uuid = 'format-uuid'; + format.id = 'format-id'; + + const expected = cold('(b|)', {b: true}); + const result = service.delete(format); + + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts new file mode 100644 index 0000000000..a5638183c0 --- /dev/null +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from '@angular/core'; +import { DataService } from './data.service'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { createSelector, select, Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { DeleteByIDRequest, FindAllOptions, PostRequest, PutRequest } from './request.models'; +import { Observable } from 'rxjs'; +import { find, map, tap } from 'rxjs/operators'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { distinctUntilChanged } from 'rxjs/internal/operators/distinctUntilChanged'; +import { RestResponse } from '../cache/response.models'; +import { AppState } from '../../app.reducer'; +import { BitstreamFormatRegistryState } from '../../+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction +} from '../../+admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { hasValue } from '../../shared/empty.util'; +import { RequestEntry } from './request.reducer'; + +const bitstreamFormatsStateSelector = (state: AppState) => state.bitstreamFormats; +const selectedBitstreamFormatSelector = createSelector(bitstreamFormatsStateSelector, + (bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats); + +/** + * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint + */ +@Injectable() +export class BitstreamFormatDataService extends DataService { + + protected linkPath = 'bitstreamformats'; + protected forceBypassCache = false; + + 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: DefaultChangeAnalyzer) { + super(); + } + + /** + * Get the endpoint for browsing bitstream formats + * @param {FindAllOptions} options + * @returns {Observable} + */ + getBrowseEndpoint(options: FindAllOptions = {}, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the endpoint to update an existing bitstream format + * @param formatId + */ + public getUpdateEndpoint(formatId: string): Observable { + return this.getBrowseEndpoint().pipe( + map((endpoint: string) => this.getIDHref(endpoint, formatId)) + ); + } + + /** + * Get the endpoint to create a new bitstream format + */ + public getCreateEndpoint(): Observable { + return this.getBrowseEndpoint(); + } + + /** + * Update an existing bitstreamFormat + * @param bitstreamFormat + */ + updateBitstreamFormat(bitstreamFormat: BitstreamFormat): Observable { + const requestId = this.requestService.generateRequestId(); + + this.getUpdateEndpoint(bitstreamFormat.id).pipe( + distinctUntilChanged(), + map((endpointURL: string) => + new PutRequest(requestId, endpointURL, bitstreamFormat)), + configureRequest(this.requestService)).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + + } + + /** + * Create a new BitstreamFormat + * @param BitstreamFormat + */ + public createBitstreamFormat(bitstreamFormat: BitstreamFormat): Observable { + const requestId = this.requestService.generateRequestId(); + + this.getCreateEndpoint().pipe( + map((endpointURL: string) => { + return new PostRequest(requestId, endpointURL, bitstreamFormat); + }), + configureRequest(this.requestService) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + } + + /** + * Clears the cache of the list of BitstreamFormats + */ + public clearBitStreamFormatRequests(): Observable { + return this.getBrowseEndpoint().pipe( + tap((href: string) => this.requestService.removeByHrefSubstring(href)) + ); + } + + /** + * Gets all the selected BitstreamFormats from the store + */ + public getSelectedBitstreamFormats(): Observable { + return this.store.pipe(select(selectedBitstreamFormatSelector)); + } + + /** + * Adds a BistreamFormat to the selected BitstreamFormats in the store + * @param bitstreamFormat + */ + public selectBitstreamFormat(bitstreamFormat: BitstreamFormat) { + this.store.dispatch(new BitstreamFormatsRegistrySelectAction(bitstreamFormat)); + } + + /** + * Removes a BistreamFormat from the list of selected BitstreamFormats in the store + * @param bitstreamFormat + */ + public deselectBitstreamFormat(bitstreamFormat: BitstreamFormat) { + this.store.dispatch(new BitstreamFormatsRegistryDeselectAction(bitstreamFormat)); + } + + /** + * Removes all BitstreamFormats from the list of selected BitstreamFormats in the store + */ + public deselectAllBitstreamFormats() { + this.store.dispatch(new BitstreamFormatsRegistryDeselectAllAction()); + } + + /** + * Delete an existing DSpace Object on the server + * @param format The DSpace Object to be removed + * Return an observable that emits true when the deletion was successful, false when it failed + */ + delete(format: BitstreamFormat): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, format.id))); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new DeleteByIDRequest(requestId, href, format.id); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response.isSuccessful) + ); + } +} 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 22d5fd3e77..08745f9223 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -105,6 +105,27 @@ export class ObjectUpdatesService { })) } + /** + * Method that combines the state's updates (excluding updates that aren't part of the initialFields) with + * the initial values (when there's no update) to create a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + */ + getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + for (const object of initialFields) { + let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; + if (isEmpty(fieldUpdate)) { + fieldUpdate = { field: object, changeType: undefined }; + } + fieldUpdates[object.uuid] = fieldUpdate; + } + return fieldUpdates; + })) + } + /** * Method to check if a specific field is currently editable in the store * @param url The URL of the page on which the field resides diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts new file mode 100644 index 0000000000..0ced517d74 --- /dev/null +++ b/src/app/core/data/relationship.service.spec.ts @@ -0,0 +1,157 @@ +import { RelationshipService } from './relationship.service'; +import { RequestService } from './request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RequestEntry } from './request.reducer'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../shared/resource-type'; +import { Relationship } from '../shared/item-relationships/relationship.model'; +import { RemoteData } from './remote-data'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { Item } from '../shared/item.model'; +import { PaginatedList } from './paginated-list'; +import { PageInfo } from '../shared/page-info.model'; +import { DeleteRequest } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Observable } from 'rxjs/internal/Observable'; + +describe('RelationshipService', () => { + let service: RelationshipService; + let requestService: RequestService; + + const restEndpointURL = 'https://rest.api/'; + const relationshipsEndpointURL = `${restEndpointURL}/relationships`; + const halService: any = new HALEndpointServiceStub(restEndpointURL); + const rdbService = getMockRemoteDataBuildService(); + const objectCache = Object.assign({ + /* tslint:disable:no-empty */ + remove: () => {} + /* tslint:enable:no-empty */ + }) as ObjectCacheService; + + const relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftLabel: 'isAuthorOfPublication', + rightLabel: 'isPublicationOfAuthor' + }); + + const relationship1 = Object.assign(new Relationship(), { + self: relationshipsEndpointURL + '/2', + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + const relationship2 = Object.assign(new Relationship(), { + self: relationshipsEndpointURL + '/3', + id: '3', + uuid: '3', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + + const relationships = [ relationship1, relationship2 ]; + + const item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + const relatedItem1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + const relatedItem2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + relationship1.leftItem = getRemotedataObservable(relatedItem1); + relationship1.rightItem = getRemotedataObservable(item); + relationship2.leftItem = getRemotedataObservable(relatedItem2); + relationship2.rightItem = getRemotedataObservable(item); + const relatedItems = [relatedItem1, relatedItem2]; + + const itemService = jasmine.createSpyObj('itemService', { + findById: (uuid) => new RemoteData(false, false, true, undefined, relatedItems.filter((relatedItem) => relatedItem.id === uuid)[0]) + }); + + function initTestService() { + return new RelationshipService( + requestService, + halService, + rdbService, + itemService, + objectCache + ); + } + + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: relationships } as any + } as RequestEntry) + }; + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntry$(true)); + service = initTestService(); + }); + + describe('deleteRelationship', () => { + beforeEach(() => { + spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); + spyOn(objectCache, 'remove'); + service.deleteRelationship(relationships[0].uuid).subscribe(); + }); + + it('should send a DeleteRequest', () => { + const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); + }); + + it('should clear the related items their cache', () => { + expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); + expect(objectCache.remove).toHaveBeenCalledWith(item.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); + }); + }); + + describe('getItemRelationshipsArray', () => { + it('should return the item\'s relationships in the form of an array', () => { + service.getItemRelationshipsArray(item).subscribe((result) => { + expect(result).toEqual(relationships); + }); + }); + }); + + describe('getItemRelationshipLabels', () => { + it('should return the correct labels', () => { + service.getItemRelationshipLabels(item).subscribe((result) => { + expect(result).toEqual([relationshipType.rightLabel]); + }); + }); + }); + + describe('getRelatedItems', () => { + it('should return the related items', () => { + service.getRelatedItems(item).subscribe((result) => { + expect(result).toEqual(relatedItems); + }); + }); + }); + + describe('getRelatedItemsByLabel', () => { + it('should return the related items by label', () => { + service.getRelatedItemsByLabel(item, relationshipType.rightLabel).subscribe((result) => { + expect(result).toEqual(relatedItems); + }); + }); + }) + +}); + +function getRemotedataObservable(obj: any): Observable> { + return observableOf(new RemoteData(false, false, true, undefined, obj)); +} diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts new file mode 100644 index 0000000000..1699b6a27d --- /dev/null +++ b/src/app/core/data/relationship.service.ts @@ -0,0 +1,235 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { hasValue, hasValueOperator, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { + configureRequest, + filterSuccessfulResponses, + getRemoteDataPayload, getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; +import { DeleteRequest, RestRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestResponse } from '../cache/response.models'; +import { Item } from '../shared/item.model'; +import { Relationship } from '../shared/item-relationships/relationship.model'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { RemoteData } from './remote-data'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { zip as observableZip } from 'rxjs'; +import { PaginatedList } from './paginated-list'; +import { ItemDataService } from './item-data.service'; +import { + compareArraysUsingIds, filterRelationsByTypeLabel, + relationsToItems +} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { ObjectCacheService } from '../cache/object-cache.service'; + +/** + * The service handling all relationship requests + */ +@Injectable() +export class RelationshipService { + protected linkPath = 'relationships'; + + constructor(protected requestService: RequestService, + protected halService: HALEndpointService, + protected rdbService: RemoteDataBuildService, + protected itemService: ItemDataService, + protected objectCache: ObjectCacheService) { + } + + /** + * Get the endpoint for a relationship by ID + * @param uuid + */ + getRelationshipEndpoint(uuid: string) { + return this.halService.getEndpoint(this.linkPath).pipe( + map((href: string) => `${href}/${uuid}`) + ); + } + + /** + * Find a relationship by its UUID + * @param uuid + */ + findById(uuid: string): Observable> { + const href$ = this.getRelationshipEndpoint(uuid); + return this.rdbService.buildSingle(href$); + } + + /** + * Send a delete request for a relationship by ID + * @param uuid + */ + deleteRelationship(uuid: string): Observable { + return this.getRelationshipEndpoint(uuid).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), + configureRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), + getResponseFromEntry(), + tap(() => this.clearRelatedCache(uuid)) + ); + } + + /** + * Get a combined observable containing an array of all relationships in an item, as well as an array of the relationships their types + * This is used for easier access of a relationship's type because they exist as observables + * @param item + */ + getItemResolvedRelsAndTypes(item: Item): Observable<[Relationship[], RelationshipType[]]> { + return observableCombineLatest( + this.getItemRelationshipsArray(item), + this.getItemRelationshipTypesArray(item) + ); + } + + /** + * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships their types + * This is used for easier access of a relationship's type and left and right items because they exist as observables + * @param item + */ + getItemResolvedRelatedItemsAndTypes(item: Item): Observable<[Item[], Item[], RelationshipType[]]> { + return observableCombineLatest( + this.getItemLeftRelatedItemArray(item), + this.getItemRightRelatedItemArray(item), + this.getItemRelationshipTypesArray(item) + ); + } + + /** + * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships themselves + * This is used for easier access of the relationship and their left and right items because they exist as observables + * @param item + */ + getItemResolvedRelatedItemsAndRelationships(item: Item): Observable<[Item[], Item[], Relationship[]]> { + return observableCombineLatest( + this.getItemLeftRelatedItemArray(item), + this.getItemRightRelatedItemArray(item), + this.getItemRelationshipsArray(item) + ); + } + + /** + * Get an item their relationships in the form of an array + * @param item + */ + getItemRelationshipsArray(item: Item): Observable { + return item.relationships.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((rels: PaginatedList) => rels.page), + hasValueOperator(), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item their relationship types in the form of an array + * @param item + */ + getItemRelationshipTypesArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => + observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe( + map(([...arr]: Array>) => arr.map((d: RemoteData) => d.payload).filter((type) => hasValue(type))), + filter((arr) => arr.length === rels.length) + ) + ), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item his relationship's left-side related items in the form of an array + * @param item + */ + getItemLeftRelatedItemArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.leftItem)).pipe( + map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), + filter((arr) => arr.length === rels.length) + )), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item his relationship's right-side related items in the form of an array + * @param item + */ + getItemRightRelatedItemArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.rightItem)).pipe( + map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), + filter((arr) => arr.length === rels.length) + )), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an array of an item their unique relationship type's labels + * The array doesn't contain any duplicate labels + * @param item + */ + getItemRelationshipLabels(item: Item): Observable { + return this.getItemResolvedRelatedItemsAndTypes(item).pipe( + map(([leftItems, rightItems, relTypesCurrentPage]) => { + return relTypesCurrentPage.map((type, index) => { + if (leftItems[index].uuid === item.uuid) { + return type.leftLabel; + } else { + return type.rightLabel; + } + }); + }), + map((labels: string[]) => Array.from(new Set(labels))) + ) + } + + /** + * Resolve a given item's relationships into related items and return the items as an array + * @param item + */ + getRelatedItems(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + relationsToItems(item.uuid) + ); + } + + /** + * Resolve a given item's relationships into related items, filtered by a relationship label + * and return the items as an array + * @param item + * @param label + */ + getRelatedItemsByLabel(item: Item, label: string): Observable { + return this.getItemResolvedRelsAndTypes(item).pipe( + filterRelationsByTypeLabel(label), + relationsToItems(item.uuid) + ); + } + + /** + * Clear object and request caches of the items related to a relationship (left and right items) + * @param uuid + */ + clearRelatedCache(uuid: string) { + this.findById(uuid).pipe( + getSucceededRemoteData(), + flatMap((rd: RemoteData) => observableCombineLatest(rd.payload.leftItem.pipe(getSucceededRemoteData()), rd.payload.rightItem.pipe(getSucceededRemoteData()))), + take(1) + ).subscribe(([leftItem, rightItem]) => { + this.objectCache.remove(leftItem.payload.self); + this.objectCache.remove(rightItem.payload.self); + this.requestService.removeByHrefSubstring(leftItem.payload.self); + this.requestService.removeByHrefSubstring(rightItem.payload.self); + }); + } + +} diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 83071382ed..775118dbc0 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -65,8 +65,7 @@ const uuidsFromHrefSubstringSelector = const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => { let result = []; if (isNotEmpty(state)) { - result = Object.values(state) - .filter((value: string) => value.startsWith(href)); + result = Object.keys(state).filter((key) => key.startsWith(href)).map((key) => state[key]); } return result; }; @@ -315,4 +314,15 @@ export class RequestService { return result; } + /** + * Create an observable that emits a new value whenever the availability of the cached request changes. + * The value it emits is a boolean stating if the request exists in cache or not. + * @param href The href of the request to observe + */ + hasByHrefObservable(href: string): Observable { + return this.getByHref(href).pipe( + map((requestEntry: RequestEntry) => this.isValid(requestEntry)) + ); + } + } diff --git a/src/app/core/registry/mock-bitstream-format.model.ts b/src/app/core/registry/mock-bitstream-format.model.ts deleted file mode 100644 index f5811e367c..0000000000 --- a/src/app/core/registry/mock-bitstream-format.model.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class BitstreamFormat { - shortDescription: string; - description: string; - mimetype: string; - supportLevel: number; - internal: boolean; - extensions: string; -} diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index 47e306d624..455a8043da 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -12,7 +12,6 @@ import { PageInfo } from '../shared/page-info.model'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { - RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse, RegistryMetadataschemasSuccessResponse, RestResponse @@ -20,7 +19,6 @@ import { import { Component } from '@angular/core'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; -import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; import { map } from 'rxjs/operators'; import { Store, StoreModule } from '@ngrx/store'; import { MockStore } from '../../shared/testing/mock-store'; @@ -44,7 +42,7 @@ import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; -@Component({ template: '' }) +@Component({template: ''}) class DummyComponent { } @@ -127,7 +125,7 @@ describe('RegistryService', () => { toRemoteDataObservable: (requestEntryObs: Observable, payloadObs: Observable) => { return observableCombineLatest(requestEntryObs, payloadObs).pipe(map(([req, pay]) => { - return { req, pay }; + return {req, pay}; }) ); }, @@ -143,11 +141,11 @@ describe('RegistryService', () => { DummyComponent ], providers: [ - { provide: RequestService, useValue: getMockRequestService() }, - { provide: RemoteDataBuildService, useValue: rdbStub }, - { provide: HALEndpointService, useValue: halServiceStub }, - { provide: Store, useClass: MockStore }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + {provide: RequestService, useValue: getMockRequestService()}, + {provide: RemoteDataBuildService, useValue: rdbStub}, + {provide: HALEndpointService, useValue: halServiceStub}, + {provide: Store, useClass: MockStore}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()}, RegistryService ] }); @@ -162,7 +160,7 @@ describe('RegistryService', () => { page: pageInfo }); const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); - const responseEntry = Object.assign(new RequestEntry(), { response: response }); + const responseEntry = Object.assign(new RequestEntry(), {response: response}); beforeEach(() => { (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); @@ -191,7 +189,7 @@ describe('RegistryService', () => { page: pageInfo }); const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); - const responseEntry = Object.assign(new RequestEntry(), { response: response }); + const responseEntry = Object.assign(new RequestEntry(), {response: response}); beforeEach(() => { (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); @@ -220,7 +218,7 @@ describe('RegistryService', () => { page: pageInfo }); const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, 200, 'OK', pageInfo); - const responseEntry = Object.assign(new RequestEntry(), { response: response }); + const responseEntry = Object.assign(new RequestEntry(), {response: response}); beforeEach(() => { (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); @@ -243,35 +241,6 @@ describe('RegistryService', () => { }); }); - describe('when requesting bitstreamformats', () => { - const queryResponse = Object.assign(new RegistryBitstreamformatsResponse(), { - bitstreamformats: mockFieldsList, - page: pageInfo - }); - const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, 200, 'OK', pageInfo); - const responseEntry = Object.assign(new RequestEntry(), { response: response }); - - beforeEach(() => { - (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); - /* tslint:disable:no-empty */ - registryService.getBitstreamFormats(pagination).subscribe((value) => { - }); - /* tslint:enable:no-empty */ - }); - - it('should call getEndpoint on the halService', () => { - expect((registryService as any).halService.getEndpoint).toHaveBeenCalled(); - }); - - it('should send out the request on the request service', () => { - expect((registryService as any).requestService.configure).toHaveBeenCalled(); - }); - - it('should call getByHref on the request service with the correct request url', () => { - expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); - }); - }); - describe('when dispatching to the store', () => { beforeEach(() => { spyOn(mockStore, 'dispatch'); @@ -284,7 +253,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryEditSchemaAction with the correct schema', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditSchemaAction(mockSchemasList[0])); - }) + }); }); describe('when calling cancelEditMetadataSchema', () => { @@ -294,7 +263,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryCancelSchemaAction', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelSchemaAction()); - }) + }); }); describe('when calling selectMetadataSchema', () => { @@ -304,7 +273,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistrySelectSchemaAction with the correct schema', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectSchemaAction(mockSchemasList[0])); - }) + }); }); describe('when calling deselectMetadataSchema', () => { @@ -314,7 +283,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryDeselectSchemaAction with the correct schema', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectSchemaAction(mockSchemasList[0])); - }) + }); }); describe('when calling deselectAllMetadataSchema', () => { @@ -324,7 +293,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryDeselectAllSchemaAction', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllSchemaAction()); - }) + }); }); describe('when calling editMetadataField', () => { @@ -334,7 +303,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryEditFieldAction with the correct Field', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditFieldAction(mockFieldsList[0])); - }) + }); }); describe('when calling cancelEditMetadataField', () => { @@ -344,7 +313,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryCancelFieldAction', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelFieldAction()); - }) + }); }); describe('when calling selectMetadataField', () => { @@ -354,7 +323,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistrySelectFieldAction with the correct Field', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectFieldAction(mockFieldsList[0])); - }) + }); }); describe('when calling deselectMetadataField', () => { @@ -364,7 +333,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryDeselectFieldAction with the correct Field', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectFieldAction(mockFieldsList[0])); - }) + }); }); describe('when calling deselectAllMetadataField', () => { @@ -374,7 +343,7 @@ describe('RegistryService', () => { it('should dispatch a MetadataRegistryDeselectAllFieldAction', () => { expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllFieldAction()); - }) + }); }); }); @@ -417,7 +386,7 @@ describe('RegistryService', () => { result.subscribe((response: RestResponse) => { expect(response.isSuccessful).toBe(true); }); - }) + }); }); describe('when deleteMetadataField is called', () => { @@ -431,7 +400,7 @@ describe('RegistryService', () => { result.subscribe((response: RestResponse) => { expect(response.isSuccessful).toBe(true); }); - }) + }); }); describe('when clearMetadataSchemaRequests is called', () => { diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index d816c5eab8..206426588e 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -3,13 +3,13 @@ import { Injectable } from '@angular/core'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list'; import { PageInfo } from '../shared/page-info.model'; -import { BitstreamFormat } from './mock-bitstream-format.model'; import { CreateMetadataFieldRequest, CreateMetadataSchemaRequest, DeleteRequest, GetRequest, - RestRequest, UpdateMetadataFieldRequest, + RestRequest, + UpdateMetadataFieldRequest, UpdateMetadataSchemaRequest } from '../data/request.models'; import { GenericConstructor } from '../shared/generic-constructor'; @@ -19,24 +19,19 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { RequestService } from '../data/request.service'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; import { - ErrorResponse, MetadatafieldSuccessResponse, MetadataschemaSuccessResponse, - RegistryBitstreamformatsSuccessResponse, + MetadatafieldSuccessResponse, + MetadataschemaSuccessResponse, RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse, RestResponse + RegistryMetadataschemasSuccessResponse, + RestResponse } from '../cache/response.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; -import { hasValue, hasNoValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service'; -import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; -import { - configureRequest, - getResponseFromEntry, - getSucceededRemoteData -} from '../shared/operators'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; import { createSelector, select, Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers'; @@ -52,9 +47,8 @@ import { MetadataRegistrySelectFieldAction, MetadataRegistrySelectSchemaAction } from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions'; -import { distinctUntilChanged, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { distinctUntilChanged, flatMap, map, take, tap } from 'rxjs/operators'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { ResourceType } from '../shared/resource-type'; import { NormalizedMetadataSchema } from '../metadata/normalized-metadata-schema.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; @@ -79,7 +73,8 @@ export class RegistryService { private metadataSchemasPath = 'metadataschemas'; private metadataFieldsPath = 'metadatafields'; - private bitstreamFormatsPath = 'bitstreamformats'; + + // private bitstreamFormatsPath = 'bitstreamformats'; constructor(protected requestService: RequestService, private rdb: RemoteDataBuildService, @@ -197,7 +192,7 @@ export class RegistryService { */ public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable>> { if (hasNoValue(pagination)) { - pagination = { currentPage: 1, pageSize: 10000 } as any; + pagination = {currentPage: 1, pageSize: 10000} as any; } const requestObs = this.getMetadataFieldsRequestObs(pagination); @@ -231,41 +226,7 @@ export class RegistryService { return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } - /** - * Retrieves all bitstream formats - * @param pagination The pagination info used to retrieve the bitstream formats - */ - public getBitstreamFormats(pagination: PaginationComponentOptions): Observable>> { - const requestObs = this.getBitstreamFormatsRequestObs(pagination); - - const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const rbrObs: Observable = requestEntryObs.pipe( - getResponseFromEntry(), - map((response: RegistryBitstreamformatsSuccessResponse) => response.bitstreamformatsResponse) - ); - - const bitstreamformatsObs: Observable = rbrObs.pipe( - map((rbr: RegistryBitstreamformatsResponse) => rbr.bitstreamformats) - ); - - const pageInfoObs: Observable = requestEntryObs.pipe( - getResponseFromEntry(), - map((response: RegistryBitstreamformatsSuccessResponse) => response.pageInfo) - ); - - const payloadObs = observableCombineLatest(bitstreamformatsObs, pageInfoObs).pipe( - map(([bitstreamformats, pageInfo]) => { - return new PaginatedList(pageInfo, bitstreamformats); - }) - ); - - return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); - } - - private getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable { + public getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable { return this.halService.getEndpoint(this.metadataSchemasPath).pipe( map((url: string) => { const args: string[] = []; @@ -327,30 +288,6 @@ export class RegistryService { ); } - private getBitstreamFormatsRequestObs(pagination: PaginationComponentOptions): Observable { - return this.halService.getEndpoint(this.bitstreamFormatsPath).pipe( - map((url: string) => { - const args: string[] = []; - args.push(`size=${pagination.pageSize}`); - args.push(`page=${pagination.currentPage - 1}`); - if (isNotEmpty(args)) { - url = new URLCombiner(url, `?${args.join('&')}`).toString(); - } - const request = new GetRequest(this.requestService.generateRequestId(), url); - return Object.assign(request, { - getResponseParser(): GenericConstructor { - return RegistryBitstreamformatsResponseParsingService; - } - }); - }), - tap((request: RestRequest) => this.requestService.configure(request)), - ); - } - - /** - * Method to start editing a metadata schema, dispatches an edit schema action - * @param schema The schema that's being edited - */ public editMetadataSchema(schema: MetadataSchema) { this.store.dispatch(new MetadataRegistryEditSchemaAction(schema)); } @@ -374,7 +311,7 @@ export class RegistryService { * @param schema The schema that's being selected */ public selectMetadataSchema(schema: MetadataSchema) { - this.store.dispatch(new MetadataRegistrySelectSchemaAction(schema)) + this.store.dispatch(new MetadataRegistrySelectSchemaAction(schema)); } /** @@ -382,14 +319,14 @@ export class RegistryService { * @param schema The schema that's it being deselected */ public deselectMetadataSchema(schema: MetadataSchema) { - this.store.dispatch(new MetadataRegistryDeselectSchemaAction(schema)) + this.store.dispatch(new MetadataRegistryDeselectSchemaAction(schema)); } /** * Method to deselect all currently selected metadata schema, dispatches a deselect all schema action */ public deselectAllMetadataSchema() { - this.store.dispatch(new MetadataRegistryDeselectAllSchemaAction()) + this.store.dispatch(new MetadataRegistryDeselectAllSchemaAction()); } /** @@ -423,20 +360,20 @@ export class RegistryService { * @param field The field that's being selected */ public selectMetadataField(field: MetadataField) { - this.store.dispatch(new MetadataRegistrySelectFieldAction(field)) + this.store.dispatch(new MetadataRegistrySelectFieldAction(field)); } /** * Method to deselect a metadata field, dispatches a deselect field action * @param field The field that's it being deselected */ public deselectMetadataField(field: MetadataField) { - this.store.dispatch(new MetadataRegistryDeselectFieldAction(field)) + this.store.dispatch(new MetadataRegistryDeselectFieldAction(field)); } /** * Method to deselect all currently selected metadata fields, dispatches a deselect all field action */ public deselectAllMetadataField() { - this.store.dispatch(new MetadataRegistryDeselectAllFieldAction()) + this.store.dispatch(new MetadataRegistryDeselectAllFieldAction()); } /** @@ -494,7 +431,7 @@ export class RegistryService { this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1)); } } else { - this.showNotifications(true, isUpdate, false, { prefix: schema.prefix }); + this.showNotifications(true, isUpdate, false, {prefix: schema.prefix}); return response; } }), @@ -521,7 +458,7 @@ export class RegistryService { public clearMetadataSchemaRequests(): Observable { return this.halService.getEndpoint(this.metadataSchemasPath).pipe( tap((href: string) => this.requestService.removeByHrefSubstring(href)) - ) + ); } /** @@ -571,7 +508,7 @@ export class RegistryService { } } else { const fieldString = `${field.schema.prefix}.${field.element}${field.qualifier ? `.${field.qualifier}` : ''}`; - this.showNotifications(true, isUpdate, true, { field: fieldString }); + this.showNotifications(true, isUpdate, true, {field: fieldString}); return response; } }), @@ -597,7 +534,7 @@ export class RegistryService { public clearMetadataFieldRequests(): Observable { return this.halService.getEndpoint(this.metadataFieldsPath).pipe( tap((href: string) => this.requestService.removeByHrefSubstring(href)) - ) + ); } private delete(path: string, id: number): Observable { @@ -633,9 +570,9 @@ export class RegistryService { ); messages.subscribe(([head, content]) => { if (success) { - this.notificationsService.success(head, content) + this.notificationsService.success(head, content); } else { - this.notificationsService.error(head, content) + this.notificationsService.error(head, content); } }); } diff --git a/src/app/shared/services/api.service.ts b/src/app/core/services/api.service.ts similarity index 100% rename from src/app/shared/services/api.service.ts rename to src/app/core/services/api.service.ts diff --git a/src/app/shared/services/client-cookie.service.ts b/src/app/core/services/client-cookie.service.ts similarity index 100% rename from src/app/shared/services/client-cookie.service.ts rename to src/app/core/services/client-cookie.service.ts diff --git a/src/app/shared/services/cookie.service.spec.ts b/src/app/core/services/cookie.service.spec.ts similarity index 100% rename from src/app/shared/services/cookie.service.spec.ts rename to src/app/core/services/cookie.service.spec.ts diff --git a/src/app/shared/services/cookie.service.ts b/src/app/core/services/cookie.service.ts similarity index 100% rename from src/app/shared/services/cookie.service.ts rename to src/app/core/services/cookie.service.ts diff --git a/src/app/shared/services/route.actions.ts b/src/app/core/services/route.actions.ts similarity index 100% rename from src/app/shared/services/route.actions.ts rename to src/app/core/services/route.actions.ts diff --git a/src/app/shared/services/route.effects.ts b/src/app/core/services/route.effects.ts similarity index 100% rename from src/app/shared/services/route.effects.ts rename to src/app/core/services/route.effects.ts diff --git a/src/app/shared/services/route.reducer.ts b/src/app/core/services/route.reducer.ts similarity index 100% rename from src/app/shared/services/route.reducer.ts rename to src/app/core/services/route.reducer.ts diff --git a/src/app/shared/services/route.service.spec.ts b/src/app/core/services/route.service.spec.ts similarity index 97% rename from src/app/shared/services/route.service.spec.ts rename to src/app/core/services/route.service.spec.ts index c6003521a7..ae31f28384 100644 --- a/src/app/shared/services/route.service.spec.ts +++ b/src/app/core/services/route.service.spec.ts @@ -6,9 +6,9 @@ import { Store } from '@ngrx/store'; import { getTestScheduler, hot } from 'jasmine-marbles'; import { RouteService } from './route.service'; -import { MockRouter } from '../mocks/mock-router'; +import { MockRouter } from '../../shared/mocks/mock-router'; import { TestScheduler } from 'rxjs/testing'; -import { AddUrlToHistoryAction } from '../history/history.actions'; +import { AddUrlToHistoryAction } from '../../shared/history/history.actions'; describe('RouteService', () => { let scheduler: TestScheduler; diff --git a/src/app/shared/services/route.service.ts b/src/app/core/services/route.service.ts similarity index 95% rename from src/app/shared/services/route.service.ts rename to src/app/core/services/route.service.ts index dc626484c1..65aa858945 100644 --- a/src/app/shared/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -12,12 +12,12 @@ import { combineLatest, Observable } from 'rxjs'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { isEqual } from 'lodash'; -import { AddUrlToHistoryAction } from '../history/history.actions'; -import { historySelector } from '../history/selectors'; +import { AddUrlToHistoryAction } from '../../shared/history/history.actions'; +import { historySelector } from '../../shared/history/selectors'; import { SetParametersAction, SetQueryParametersAction } from './route.actions'; -import { CoreState } from '../../core/core.reducers'; -import { hasValue } from '../empty.util'; -import { coreSelector } from '../../core/core.selectors'; +import { CoreState } from '../core.reducers'; +import { hasValue } from '../../shared/empty.util'; +import { coreSelector } from '../core.selectors'; /** * Selector to select all route parameters from the store diff --git a/src/app/shared/services/server-cookie.service.ts b/src/app/core/services/server-cookie.service.ts similarity index 100% rename from src/app/shared/services/server-cookie.service.ts rename to src/app/core/services/server-cookie.service.ts diff --git a/src/app/shared/services/server-response.service.ts b/src/app/core/services/server-response.service.ts similarity index 100% rename from src/app/shared/services/server-response.service.ts rename to src/app/core/services/server-response.service.ts diff --git a/src/app/shared/services/window.service.ts b/src/app/core/services/window.service.ts similarity index 100% rename from src/app/shared/services/window.service.ts rename to src/app/core/services/window.service.ts diff --git a/src/app/core/shared/bitstream-format-support-level.ts b/src/app/core/shared/bitstream-format-support-level.ts new file mode 100644 index 0000000000..d92aac7708 --- /dev/null +++ b/src/app/core/shared/bitstream-format-support-level.ts @@ -0,0 +1,5 @@ +export enum BitstreamFormatSupportLevel { + Known = 'KNOWN', + Unknown = 'UNKNOWN', + Supported = 'SUPPORTED' +} diff --git a/src/app/core/shared/bitstream-format.model.ts b/src/app/core/shared/bitstream-format.model.ts index bf50cd832f..0e1279e978 100644 --- a/src/app/core/shared/bitstream-format.model.ts +++ b/src/app/core/shared/bitstream-format.model.ts @@ -1,6 +1,6 @@ - import { CacheableObject, TypedObject } from '../cache/object-cache.reducer'; import { ResourceType } from './resource-type'; +import { BitstreamFormatSupportLevel } from './bitstream-format-support-level'; /** * Model class for a Bitstream Format @@ -27,7 +27,7 @@ export class BitstreamFormat implements CacheableObject { /** * The level of support the system offers for this Bitstream Format */ - supportLevel: number; + supportLevel: BitstreamFormatSupportLevel; /** * True if the Bitstream Format is used to store system information, rather than the content of items in the system @@ -37,7 +37,7 @@ export class BitstreamFormat implements CacheableObject { /** * String representing this Bitstream Format's file extension */ - extensions: string; + extensions: string[]; /** * The link to the rest endpoint where this Bitstream Format can be found @@ -49,4 +49,11 @@ export class BitstreamFormat implements CacheableObject { */ uuid: string; + /** + * Identifier for this Bitstream Format + * Note that this ID is unique for bitstream formats, + * but might not be unique across different object types + */ + id: string; + } diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 515ea3cd69..a3e625c022 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -5,10 +5,11 @@ 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, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { PaginatedList } from '../data/paginated-list'; import { Relationship } from './item-relationships/relationship.model'; import { ResourceType } from './resource-type'; +import { getSucceededRemoteData } from './operators'; export class Item extends DSpaceObject { static type = new ResourceType('item'); @@ -97,7 +98,7 @@ export class Item extends DSpaceObject { */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams.pipe( - filter((rd: RemoteData>) => !rd.isResponsePending && isNotUndefined(rd.payload)), + getSucceededRemoteData(), map((rd: RemoteData>) => rd.payload.page), filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), take(1), diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index ae46691e39..d46c688e68 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -91,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.indexableObject); + const dsoPage: T[] = rd.payload.page.filter((result) => hasValue(result)).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/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html new file mode 100644 index 0000000000..3aa79fc70a --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html @@ -0,0 +1,30 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.scss b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts new file mode 100644 index 0000000000..d13feda406 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts @@ -0,0 +1,50 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { JournalIssueGridElementComponent } from './journal-issue-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'creativework.datePublished': [ + { + language: null, + value: '2015-06-26' + } + ], + 'journal.title': [ + { + language: 'en_US', + value: 'The journal title' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalIssueGridElementComponent', getEntityGridElementTestComponent(JournalIssueGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'journal-title'])); diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.ts new file mode 100644 index 0000000000..06c27ebacf --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('JournalIssue', ItemViewMode.Card) +@Component({ + selector: 'ds-journal-issue-grid-element', + styleUrls: ['./journal-issue-grid-element.component.scss'], + templateUrl: './journal-issue-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Journal Issue + */ +export class JournalIssueGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html new file mode 100644 index 0000000000..b2b251f550 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html @@ -0,0 +1,30 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.scss b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts new file mode 100644 index 0000000000..8c854aeb77 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts @@ -0,0 +1,50 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { JournalVolumeGridElementComponent } from './journal-volume-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'creativework.datePublished': [ + { + language: null, + value: '2015-06-26' + } + ], + 'dc.description': [ + { + language: 'en_US', + value: 'A description for the journal volume' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalVolumeGridElementComponent', getEntityGridElementTestComponent(JournalVolumeGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'description'])); diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.ts new file mode 100644 index 0000000000..e5183536ef --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('JournalVolume', ItemViewMode.Card) +@Component({ + selector: 'ds-journal-volume-grid-element', + styleUrls: ['./journal-volume-grid-element.component.scss'], + templateUrl: './journal-volume-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Journal Volume + */ +export class JournalVolumeGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html new file mode 100644 index 0000000000..af0739004c --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html @@ -0,0 +1,35 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + {{dso.firstMetadataValue('creativework.editor')}} + + , + {{dso.firstMetadataValue('creativework.publisher')}} + + +

+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.scss b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts new file mode 100644 index 0000000000..0d0f77842a --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts @@ -0,0 +1,56 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { JournalGridElementComponent } from './journal-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'creativework.editor': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'creativework.publisher': [ + { + language: 'en_US', + value: 'A company' + } + ], + 'dc.description': [ + { + language: 'en_US', + value: 'This is the description' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalGridElementComponent', getEntityGridElementTestComponent(JournalGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['editor', 'publisher', 'description'])); diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.ts new file mode 100644 index 0000000000..7f23211538 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('Journal', ItemViewMode.Card) +@Component({ + selector: 'ds-journal-grid-element', + styleUrls: ['./journal-grid-element.component.scss'], + templateUrl: './journal-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Journal + */ +export class JournalGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/journal-entities/journal-entities.module.ts b/src/app/entity-groups/journal-entities/journal-entities.module.ts index 50ec160650..4033645e1b 100644 --- a/src/app/entity-groups/journal-entities/journal-entities.module.ts +++ b/src/app/entity-groups/journal-entities/journal-entities.module.ts @@ -9,6 +9,9 @@ import { JournalListElementComponent } from './item-list-elements/journal/journa import { JournalIssueListElementComponent } from './item-list-elements/journal-issue/journal-issue-list-element.component'; import { JournalVolumeListElementComponent } from './item-list-elements/journal-volume/journal-volume-list-element.component'; import { TooltipModule } from 'ngx-bootstrap'; +import { JournalIssueGridElementComponent } from './item-grid-elements/journal-issue/journal-issue-grid-element.component'; +import { JournalVolumeGridElementComponent } from './item-grid-elements/journal-volume/journal-volume-grid-element.component'; +import { JournalGridElementComponent } from './item-grid-elements/journal/journal-grid-element.component'; const ENTRY_COMPONENTS = [ JournalComponent, @@ -16,7 +19,10 @@ const ENTRY_COMPONENTS = [ JournalVolumeComponent, JournalListElementComponent, JournalIssueListElementComponent, - JournalVolumeListElementComponent + JournalVolumeListElementComponent, + JournalIssueGridElementComponent, + JournalVolumeGridElementComponent, + JournalGridElementComponent ]; @NgModule({ diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.html new file mode 100644 index 0000000000..a4765c4e8f --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.html @@ -0,0 +1,35 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+

+ + {{dso.firstMetadataValue('organization.address.addressCountry')}} + + , + {{dso.firstMetadataValue('organization.address.addressLocality')}} + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.scss b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts new file mode 100644 index 0000000000..15c7b75bf5 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts @@ -0,0 +1,56 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { OrgunitGridElementComponent } from './orgunit-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'organization.foundingDate': [ + { + language: null, + value: '2015-06-26' + } + ], + 'organization.address.addressCountry': [ + { + language: 'en_US', + value: 'Belgium' + } + ], + 'organization.address.addressLocality': [ + { + language: 'en_US', + value: 'Brussels' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('OrgunitGridElementComponent', getEntityGridElementTestComponent(OrgunitGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'country', 'city'])); diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.ts b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.ts new file mode 100644 index 0000000000..0effc22027 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('OrgUnit', ItemViewMode.Card) +@Component({ + selector: 'ds-orgunit-grid-element', + styleUrls: ['./orgunit-grid-element.component.scss'], + templateUrl: './orgunit-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Organisation Unit + */ +export class OrgunitGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html new file mode 100644 index 0000000000..331c2bd520 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html @@ -0,0 +1,30 @@ + +
+ +
+ + +
+
+
+ + +

+
+ +

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.scss b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts new file mode 100644 index 0000000000..25268261e1 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts @@ -0,0 +1,50 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { PersonGridElementComponent } from './person-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'person.email': [ + { + language: 'en_US', + value: 'Smith-Donald@gmail.com' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Web Developer' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('PersonGridElementComponent', getEntityGridElementTestComponent(PersonGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['email', 'jobtitle'])); diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.ts b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.ts new file mode 100644 index 0000000000..bf7b8aa119 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; +import { focusShadow } from '../../../../shared/animations/focus'; + +@rendersItemType('Person', ItemViewMode.Card) +@Component({ + selector: 'ds-person-grid-element', + styleUrls: ['./person-grid-element.component.scss'], + templateUrl: './person-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Person + */ +export class PersonGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html new file mode 100644 index 0000000000..889276b29b --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html @@ -0,0 +1,25 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.scss b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts new file mode 100644 index 0000000000..969912976c --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts @@ -0,0 +1,44 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { ProjectGridElementComponent } from './project-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.description': [ + { + language: 'en_US', + value: 'The project description' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('ProjectGridElementComponent', getEntityGridElementTestComponent(ProjectGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['description'])); diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.ts b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.ts new file mode 100644 index 0000000000..15d525fcf2 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('Project', ItemViewMode.Card) +@Component({ + selector: 'ds-project-grid-element', + styleUrls: ['./project-grid-element.component.scss'], + templateUrl: './project-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Project + */ +export class ProjectGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/research-entities/research-entities.module.ts b/src/app/entity-groups/research-entities/research-entities.module.ts index ba28f174df..099fa2a6a3 100644 --- a/src/app/entity-groups/research-entities/research-entities.module.ts +++ b/src/app/entity-groups/research-entities/research-entities.module.ts @@ -11,6 +11,9 @@ import { PersonMetadataListElementComponent } from './item-list-elements/person/ import { PersonListElementComponent } from './item-list-elements/person/person-list-element.component'; import { ProjectListElementComponent } from './item-list-elements/project/project-list-element.component'; import { TooltipModule } from 'ngx-bootstrap'; +import { PersonGridElementComponent } from './item-grid-elements/person/person-grid-element.component'; +import { OrgunitGridElementComponent } from './item-grid-elements/orgunit/orgunit-grid-element.component'; +import { ProjectGridElementComponent } from './item-grid-elements/project/project-grid-element.component'; const ENTRY_COMPONENTS = [ OrgunitComponent, @@ -20,7 +23,10 @@ const ENTRY_COMPONENTS = [ OrgUnitMetadataListElementComponent, PersonListElementComponent, PersonMetadataListElementComponent, - ProjectListElementComponent + ProjectListElementComponent, + PersonGridElementComponent, + OrgunitGridElementComponent, + ProjectGridElementComponent ]; @NgModule({ diff --git a/src/app/pagenotfound/pagenotfound.component.ts b/src/app/pagenotfound/pagenotfound.component.ts index 6e173b4139..b11de58269 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -1,4 +1,4 @@ -import { ServerResponseService } from '../shared/services/server-response.service'; +import { ServerResponseService } from '../core/services/server-response.service'; import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core'; import { AuthService } from '../core/auth/auth.service'; diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts index 6a96892b06..8d1d5c1dca 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts @@ -9,6 +9,7 @@ import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynami import { TranslateService } from '@ngx-translate/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models'; +import { ResourceType } from '../../../core/shared/resource-type'; import { isNotEmpty } from '../../empty.util'; import { Community } from '../../../core/shared/community.model'; @@ -29,7 +30,7 @@ export class ComColFormComponent implements OnInit { /** * Type of DSpaceObject that the form represents */ - protected type; + protected type: ResourceType; /** * @type {string} Key prefix used to generate form labels @@ -110,11 +111,11 @@ export class ComColFormComponent implements OnInit { private updateFieldTranslations() { this.formModel.forEach( (fieldModel: DynamicInputModel) => { - fieldModel.label = this.translate.instant(this.type + this.LABEL_KEY_PREFIX + fieldModel.id); + fieldModel.label = this.translate.instant(this.type.value + this.LABEL_KEY_PREFIX + fieldModel.id); if (isNotEmpty(fieldModel.validators)) { fieldModel.errorMessages = {}; Object.keys(fieldModel.validators).forEach((key) => { - fieldModel.errorMessages[key] = this.translate.instant(this.type + this.ERROR_KEY_PREFIX + fieldModel.id + '.' + key); + fieldModel.errorMessages[key] = this.translate.instant(this.type.value + this.ERROR_KEY_PREFIX + fieldModel.id + '.' + key); }); } } diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts index 08f15ad052..6ad2e5b5e1 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts @@ -1,6 +1,6 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { CommunityDataService } from '../../../core/data/community-data.service'; -import { RouteService } from '../../services/route.service'; +import { RouteService } from '../../../core/services/route.service'; import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index c9fcfecb97..e07f2a5a0a 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Community } from '../../../core/shared/community.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; import { Observable } from 'rxjs'; -import { RouteService } from '../../services/route.service'; +import { RouteService } from '../../../core/services/route.service'; import { Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; import { isNotEmpty, isNotUndefined } from '../../empty.util'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index cead04f797..217f9e79cf 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -14,7 +14,7 @@ - +
{{ message | translate:model.validators }} diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts index 279f78f721..1623829b15 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -29,7 +29,7 @@ export class DropdownFieldParser extends FieldParser { const dropdownModel = new DynamicScrollableDropdownModel(dropdownModelConfig, layout); return dropdownModel; } else { - throw Error(`Authority name is not available. Please checks form configuration file.`); + throw Error(`Authority name is not available. Please check the form configuration file.`); } } } diff --git a/src/app/shared/items/item-type-decorator.ts b/src/app/shared/items/item-type-decorator.ts index 2420e71908..3a040ae5bf 100644 --- a/src/app/shared/items/item-type-decorator.ts +++ b/src/app/shared/items/item-type-decorator.ts @@ -3,6 +3,7 @@ import { MetadataRepresentationType } from '../../core/shared/metadata-represent export enum ItemViewMode { Element = 'element', + Card = 'card', Full = 'full', Metadata = 'metadata' } diff --git a/src/app/shared/items/switcher/item-type-switcher.component.html b/src/app/shared/items/switcher/item-type-switcher.component.html index 4965359495..f2ea5784fc 100644 --- a/src/app/shared/items/switcher/item-type-switcher.component.html +++ b/src/app/shared/items/switcher/item-type-switcher.component.html @@ -1 +1 @@ - + diff --git a/src/app/shared/items/switcher/item-type-switcher.component.spec.ts b/src/app/shared/items/switcher/item-type-switcher.component.spec.ts index 3f5b7c7f90..76389201c5 100644 --- a/src/app/shared/items/switcher/item-type-switcher.component.spec.ts +++ b/src/app/shared/items/switcher/item-type-switcher.component.spec.ts @@ -1,16 +1,14 @@ import { ItemTypeSwitcherComponent } from './item-type-switcher.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { of as observableOf } from 'rxjs'; import { PageInfo } from '../../../core/shared/page-info.model'; import { Item } from '../../../core/shared/item.model'; import { PaginatedList } from '../../../core/data/paginated-list'; -import { RemoteData } from '../../../core/data/remote-data'; import * as decorator from '../item-type-decorator'; import { getComponentByItemType, ItemViewMode } from '../item-type-decorator'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; -import createSpy = jasmine.createSpy; import { createSuccessfulRemoteDataObject$ } from '../../testing/utils'; +import createSpy = jasmine.createSpy; const relationType = 'type'; const mockItem: Item = Object.assign(new Item(), { @@ -61,7 +59,7 @@ describe('ItemTypeSwitcherComponent', () => { describe('when calling getComponent', () => { beforeEach(() => { - comp.getComponent(); + (comp as any).getComponent(); }); it('should call getComponentByItemType with parameters type and viewMode', () => { @@ -79,7 +77,7 @@ describe('ItemTypeSwitcherComponent', () => { describe('when calling getComponent', () => { beforeEach(() => { - comp.getComponent(); + (comp as any).getComponent(); }); it('should call getComponentByItemType with parameters type, viewMode and representationType', () => { diff --git a/src/app/shared/items/switcher/item-type-switcher.component.ts b/src/app/shared/items/switcher/item-type-switcher.component.ts index 21a045b8f4..cd061bc1dd 100644 --- a/src/app/shared/items/switcher/item-type-switcher.component.ts +++ b/src/app/shared/items/switcher/item-type-switcher.component.ts @@ -32,6 +32,8 @@ export class ItemTypeSwitcherComponent implements OnInit { */ objectInjector: Injector; + component: any; + constructor(private injector: Injector) { } @@ -40,14 +42,14 @@ export class ItemTypeSwitcherComponent implements OnInit { providers: [{ provide: ITEM, useFactory: () => this.object, deps:[] }], parent: this.injector }); - + this.component = this.getComponent(); } /** * Fetch the component depending on the item's relationship type * @returns {string} */ - getComponent(): string { + private getComponent(): string { if (hasValue((this.object as any).representationType)) { const metadataRepresentation = this.object as MetadataRepresentation; return getComponentByItemType(metadataRepresentation.itemType, this.viewMode, metadataRepresentation.representationType); diff --git a/src/app/shared/lang-switch/lang-switch.component.html b/src/app/shared/lang-switch/lang-switch.component.html index 745facc95c..b61ec5592e 100644 --- a/src/app/shared/lang-switch/lang-switch.component.html +++ b/src/app/shared/lang-switch/lang-switch.component.html @@ -4,7 +4,7 @@