diff --git a/config/environment.test.js b/config/environment.test.js index f4d625303f..0652755bc7 100644 --- a/config/environment.test.js +++ b/config/environment.test.js @@ -1,3 +1,5 @@ module.exports = { - + theme: { + name: 'default', + } }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index f1a9120df8..bbe77f3e2f 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", @@ -235,12 +267,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", @@ -670,4 +716,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 a82c1976c8..236388109e 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '../../shared/shared.module'; +import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; import { EditItemPageComponent } from './edit-item-page.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemOperationComponent } from './item-operation/item-operation.component'; @@ -14,8 +15,10 @@ 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'; import { ItemMoveComponent } from './item-move/item-move.component'; -import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -38,8 +41,11 @@ import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; ItemDeleteComponent, ItemStatusComponent, ItemMetadataComponent, + ItemRelationshipsComponent, ItemBitstreamsComponent, EditInPlaceFieldComponent, + EditRelationshipComponent, + EditRelationshipListComponent, ItemMoveComponent, ] }) 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 0c8dd407f9..65e2a36fd1 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 @@ -11,15 +11,13 @@ import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemMoveComponent } from './item-move/item-move.component'; -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { getItemEditPath } from '../item-page-routing.module'; +import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; const ITEM_EDIT_PRIVATE_PATH = 'private'; const ITEM_EDIT_PUBLIC_PATH = 'public'; const ITEM_EDIT_DELETE_PATH = 'delete'; - const ITEM_EDIT_MOVE_PATH = 'move'; /** @@ -43,29 +41,34 @@ const ITEM_EDIT_MOVE_PATH = 'move'; { path: 'status', component: ItemStatusComponent, - data: {title: 'item.edit.tabs.status.title'} + data: { title: 'item.edit.tabs.status.title' } }, { path: 'bitstreams', component: ItemBitstreamsComponent, - data: {title: 'item.edit.tabs.bitstreams.title'} + data: { title: 'item.edit.tabs.bitstreams.title' } }, { path: 'metadata', component: ItemMetadataComponent, - data: {title: 'item.edit.tabs.metadata.title'} + 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 */ component: ItemBitstreamsComponent, - data: {title: 'item.edit.tabs.view.title'} + data: { title: 'item.edit.tabs.view.title' } }, { path: 'curate', /* TODO - change when curate page exists */ component: ItemBitstreamsComponent, - data: {title: 'item.edit.tabs.curate.title'} + data: { title: 'item.edit.tabs.curate.title' } }, ] }, @@ -107,7 +110,7 @@ const ITEM_EDIT_MOVE_PATH = 'move'; { path: ITEM_EDIT_MOVE_PATH, component: ItemMoveComponent, - data: {title: 'item.edit.move.title'}, + data: { title: 'item.edit.move.title' }, resolve: { item: ItemPageResolver } 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/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 03433d1da1..c268c5b7f6 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -109,7 +109,7 @@ export class SearchPageComponent implements OnInit { ngOnInit(): void { 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); }); diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 86611cc87b..be95ed096e 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'; @@ -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..bc3d0d1504 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -44,6 +44,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { RouteService } from './shared/services/route.service'; import { MockActivatedRoute } from './shared/mocks/mock-active-router'; import { MockRouter } from './shared/mocks/mock-router'; +import { CookieService } from './shared/services/cookie.service'; +import { MockCookieService } from './shared/mocks/mock-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..632b812cb5 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -32,6 +32,11 @@ 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 { ClientCookieService } from './shared/services/client-cookie.service'; +import { isNotEmpty } from './shared/empty.util'; +import { CookieService } from './shared/services/cookie.service'; + +export const LANG_COOKIE = 'language_cookie'; @Component({ selector: 'ds-app', @@ -61,6 +66,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 +74,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..3781edf532 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 './shared/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/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.module.ts b/src/app/core/core.module.ts index 1d22b5fefe..6054752edb 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -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/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/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..4cb34a140b --- /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..d7c9b68a24 --- /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..467cdd1594 --- /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..104d3a0a57 --- /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..86353377fa --- /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..a595791cc4 --- /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/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/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/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/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 @@