diff --git a/resources/i18n/en.json b/resources/i18n/en.json
index 93fc7e1ead..a05cb6c24e 100644
--- a/resources/i18n/en.json
+++ b/resources/i18n/en.json
@@ -364,11 +364,18 @@
"title": "DSpace Angular :: Metadata Registry",
"head": "Metadata Registry",
"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.",
+ "form": {
+ "create": "Create metadata schema",
+ "edit": "Edit metadata schema",
+ "namespace": "Namespace",
+ "name": "Name"
+ },
"schemas": {
"table": {
"id": "ID",
"namespace": "Namespace",
- "name": "Name"
+ "name": "Name",
+ "delete": "Delete selected"
},
"no-items": "No metadata schemas to show."
}
@@ -377,13 +384,40 @@
"title": "DSpace Angular :: Metadata Schema Registry",
"head": "Metadata Schema",
"description": "This is the metadata schema for \"{{namespace}}\".",
+ "return": "Return",
+ "form": {
+ "create": "Create metadata field",
+ "edit": "Edit metadata field",
+ "element": "Element",
+ "qualifier": "Qualifier",
+ "scopenote": "Scope Note"
+ },
"fields": {
"head": "Schema metadata fields",
"table": {
"field": "Field",
- "scopenote": "Scope Note"
+ "scopenote": "Scope Note",
+ "delete": "Delete selected"
},
"no-items": "No metadata fields to show."
+ },
+ "notification": {
+ "success": "Success",
+ "failure": "Error",
+ "created": "Successfully created metadata schema \"{{prefix}}\"",
+ "edited": "Successfully edited metadata schema \"{{prefix}}\"",
+ "deleted": {
+ "success": "Successfully deleted {{amount}} metadata schemas",
+ "failure": "Failed to delete {{amount}} metadata schemas"
+ },
+ "field": {
+ "created": "Successfully created metadata field \"{{field}}\"",
+ "edited": "Successfully edited metadata field \"{{field}}\"",
+ "deleted": {
+ "success": "Successfully deleted {{amount}} metadata fields",
+ "failure": "Failed to delete {{amount}} metadata fields"
+ }
+ }
}
},
"bitstream-formats": {
diff --git a/src/app/+admin/admin-registries/admin-registries.module.ts b/src/app/+admin/admin-registries/admin-registries.module.ts
index 8ff42646ac..c7890e6697 100644
--- a/src/app/+admin/admin-registries/admin-registries.module.ts
+++ b/src/app/+admin/admin-registries/admin-registries.module.ts
@@ -7,6 +7,8 @@ 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';
@NgModule({
imports: [
@@ -19,7 +21,12 @@ import { SharedModule } from '../../shared/shared.module';
declarations: [
MetadataRegistryComponent,
MetadataSchemaComponent,
- BitstreamFormatsComponent
+ BitstreamFormatsComponent,
+ MetadataSchemaFormComponent,
+ MetadataFieldFormComponent
+ ],
+ entryComponents: [
+
]
})
export class AdminRegistriesModule {
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts
new file mode 100644
index 0000000000..92d4a7a72a
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.actions.ts
@@ -0,0 +1,132 @@
+import { Action } from '@ngrx/store';
+import { type } from '../../../shared/ngrx/type';
+import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
+import { MetadataField } from '../../../core/metadata/metadatafield.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 MetadataRegistryActionTypes = {
+
+ EDIT_SCHEMA: type('dspace/metadata-registry/EDIT_SCHEMA'),
+ CANCEL_EDIT_SCHEMA: type('dspace/metadata-registry/CANCEL_SCHEMA'),
+ SELECT_SCHEMA: type('dspace/metadata-registry/SELECT_SCHEMA'),
+ DESELECT_SCHEMA: type('dspace/metadata-registry/DESELECT_SCHEMA'),
+ DESELECT_ALL_SCHEMA: type('dspace/metadata-registry/DESELECT_ALL_SCHEMA'),
+
+ EDIT_FIELD: type('dspace/metadata-registry/EDIT_FIELD'),
+ CANCEL_EDIT_FIELD: type('dspace/metadata-registry/CANCEL_FIELD'),
+ SELECT_FIELD: type('dspace/metadata-registry/SELECT_FIELD'),
+ DESELECT_FIELD: type('dspace/metadata-registry/DESELECT_FIELD'),
+ DESELECT_ALL_FIELD: type('dspace/metadata-registry/DESELECT_ALL_FIELD')
+};
+
+/* tslint:disable:max-classes-per-file */
+/**
+ * Used to collapse the sidebar
+ */
+export class MetadataRegistryEditSchemaAction implements Action {
+ type = MetadataRegistryActionTypes.EDIT_SCHEMA;
+
+ schema: MetadataSchema;
+
+ constructor(registry: MetadataSchema) {
+ this.schema = registry;
+ }
+}
+
+/**
+ * Used to expand the sidebar
+ */
+export class MetadataRegistryCancelSchemaAction implements Action {
+ type = MetadataRegistryActionTypes.CANCEL_EDIT_SCHEMA;
+}
+
+export class MetadataRegistrySelectSchemaAction implements Action {
+ type = MetadataRegistryActionTypes.SELECT_SCHEMA;
+
+ schema: MetadataSchema;
+
+ constructor(registry: MetadataSchema) {
+ this.schema = registry;
+ }
+}
+
+export class MetadataRegistryDeselectSchemaAction implements Action {
+ type = MetadataRegistryActionTypes.DESELECT_SCHEMA;
+
+ schema: MetadataSchema;
+
+ constructor(registry: MetadataSchema) {
+ this.schema = registry;
+ }
+}
+
+export class MetadataRegistryDeselectAllSchemaAction implements Action {
+ type = MetadataRegistryActionTypes.DESELECT_ALL_SCHEMA;
+}
+
+/**
+ * Used to collapse the sidebar
+ */
+export class MetadataRegistryEditFieldAction implements Action {
+ type = MetadataRegistryActionTypes.EDIT_FIELD;
+
+ field: MetadataField;
+
+ constructor(registry: MetadataField) {
+ this.field = registry;
+ }
+}
+
+/**
+ * Used to expand the sidebar
+ */
+export class MetadataRegistryCancelFieldAction implements Action {
+ type = MetadataRegistryActionTypes.CANCEL_EDIT_FIELD;
+}
+
+export class MetadataRegistrySelectFieldAction implements Action {
+ type = MetadataRegistryActionTypes.SELECT_FIELD;
+
+ field: MetadataField;
+
+ constructor(registry: MetadataField) {
+ this.field = registry;
+ }
+}
+
+export class MetadataRegistryDeselectFieldAction implements Action {
+ type = MetadataRegistryActionTypes.DESELECT_FIELD;
+
+ field: MetadataField;
+
+ constructor(registry: MetadataField) {
+ this.field = registry;
+ }
+}
+
+export class MetadataRegistryDeselectAllFieldAction implements Action {
+ type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD;
+}
+
+/* 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
+ */
+export type MetadataRegistryAction
+ = MetadataRegistryEditSchemaAction
+ | MetadataRegistryCancelSchemaAction
+ | MetadataRegistrySelectSchemaAction
+ | MetadataRegistryDeselectSchemaAction
+ | MetadataRegistryEditFieldAction
+ | MetadataRegistryCancelFieldAction
+ | MetadataRegistrySelectFieldAction
+ | MetadataRegistryDeselectFieldAction;
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html
index 49a52cec9c..a254f20428 100644
--- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html
@@ -1,42 +1,61 @@
-
-
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.scss b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.scss
new file mode 100644
index 0000000000..8c208ffad5
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.scss
@@ -0,0 +1,5 @@
+@import '../../../../styles/variables.scss';
+
+.selectable-row:hover {
+ cursor: pointer;
+}
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts
index 8b72afa083..66088236a4 100644
--- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts
@@ -1,5 +1,5 @@
import { MetadataRegistryComponent } from './metadata-registry.component';
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
@@ -13,6 +13,10 @@ import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
import { HostWindowService } from '../../../shared/host-window.service';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
+import { RestResponse } from '../../../core/cache/response.models';
describe('MetadataRegistryComponent', () => {
let comp: MetadataRegistryComponent;
@@ -33,9 +37,18 @@ describe('MetadataRegistryComponent', () => {
}
];
const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
+ /* tslint:disable:no-empty */
const registryServiceStub = {
- getMetadataSchemas: () => mockSchemas
+ getMetadataSchemas: () => mockSchemas,
+ getActiveMetadataSchema: () => observableOf(undefined),
+ getSelectedMetadataSchemas: () => observableOf([]),
+ editMetadataSchema: (schema) => {},
+ cancelEditMetadataSchema: () => {},
+ deleteMetadataSchema: () => observableOf(new RestResponse(true, '200')),
+ deselectAllMetadataSchema: () => {},
+ clearMetadataSchemaRequests: () => observableOf(undefined)
};
+ /* tslint:enable:no-empty */
beforeEach(async(() => {
TestBed.configureTestingModule({
@@ -43,8 +56,12 @@ describe('MetadataRegistryComponent', () => {
declarations: [MetadataRegistryComponent, PaginationComponent, EnumKeysPipe],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
- { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
- ]
+ { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
+ { provide: NotificationsService, useValue: new NotificationsServiceStub() }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(MetadataRegistryComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
@@ -52,19 +69,66 @@ describe('MetadataRegistryComponent', () => {
fixture = TestBed.createComponent(MetadataRegistryComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
- registryService = (comp as any).service;
});
+ beforeEach(inject([RegistryService], (s) => {
+ registryService = s;
+ }));
+
it('should contain two schemas', () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas>tbody')).nativeElement;
expect(tbody.children.length).toBe(2);
});
it('should contain the correct schemas', () => {
- const dcName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(1) td:nth-child(3)')).nativeElement;
+ const dcName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(1) td:nth-child(4)')).nativeElement;
expect(dcName.textContent).toBe('dc');
- const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(3)')).nativeElement;
+ const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(4)')).nativeElement;
expect(mockName.textContent).toBe('mock');
});
+
+ describe('when clicking a metadata schema row', () => {
+ let row: HTMLElement;
+
+ beforeEach(() => {
+ spyOn(registryService, 'editMetadataSchema');
+ row = fixture.debugElement.query(By.css('.selectable-row')).nativeElement;
+ row.click();
+ fixture.detectChanges();
+ });
+
+ it('should start editing the selected schema', async(() => {
+ fixture.whenStable().then(() => {
+ expect(registryService.editMetadataSchema).toHaveBeenCalledWith(mockSchemasList[0]);
+ });
+ }));
+
+ it('should cancel editing the selected schema when clicked again', async(() => {
+ spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(mockSchemasList[0]));
+ spyOn(registryService, 'cancelEditMetadataSchema');
+ row.click();
+ fixture.detectChanges();
+ fixture.whenStable().then(() => {
+ expect(registryService.cancelEditMetadataSchema).toHaveBeenCalled();
+ });
+ }));
+ });
+
+ describe('when deleting metadata schemas', () => {
+ const selectedSchemas = Array(mockSchemasList[0]);
+
+ beforeEach(() => {
+ spyOn(registryService, 'deleteMetadataSchema').and.callThrough();
+ spyOn(registryService, 'getSelectedMetadataSchemas').and.returnValue(observableOf(selectedSchemas));
+ comp.deleteSchemas();
+ fixture.detectChanges();
+ });
+
+ it('should call deleteMetadataSchema with the selected id', async(() => {
+ fixture.whenStable().then(() => {
+ expect(registryService.deleteMetadataSchema).toHaveBeenCalledWith(selectedSchemas[0].id);
+ });
+ }));
+ });
});
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts
index c2f70eaa9e..88c807e3bc 100644
--- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts
@@ -1,34 +1,173 @@
import { Component } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
-import { Observable } from 'rxjs';
+import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
+import { map, take } from 'rxjs/operators';
+import { hasValue } from '../../../shared/empty.util';
+import { RestResponse } from '../../../core/cache/response.models';
+import { zip } from 'rxjs/internal/observable/zip';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { Route, Router } from '@angular/router';
+import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-metadata-registry',
- templateUrl: './metadata-registry.component.html'
+ templateUrl: './metadata-registry.component.html',
+ styleUrls: ['./metadata-registry.component.scss']
})
+/**
+ * A component used for managing all existing metadata schemas within the repository.
+ * The admin can create, edit or delete metadata schemas here.
+ */
export class MetadataRegistryComponent {
+ /**
+ * A list of all the current metadata schemas within the repository
+ */
metadataSchemas: Observable
>>;
+
+ /**
+ * Pagination config used to display the list of metadata schemas
+ */
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-metadataschemas-pagination',
- pageSize: 10000
+ pageSize: 25
});
- constructor(private registryService: RegistryService) {
+ constructor(private registryService: RegistryService,
+ private notificationsService: NotificationsService,
+ private router: Router,
+ private translateService: TranslateService) {
this.updateSchemas();
}
+ /**
+ * Event triggered when the user changes page
+ * @param event
+ */
onPageChange(event) {
this.config.currentPage = event;
this.updateSchemas();
}
+ /**
+ * Update the list of schemas by fetching it from the rest api or cache
+ */
private updateSchemas() {
this.metadataSchemas = this.registryService.getMetadataSchemas(this.config);
}
+ /**
+ * Force-update the list of schemas by first clearing the cache related to metadata schemas, then performing
+ * a new REST call
+ */
+ public forceUpdateSchemas() {
+ this.registryService.clearMetadataSchemaRequests().subscribe();
+ this.updateSchemas();
+ }
+
+ /**
+ * Start editing the selected metadata schema
+ * @param schema
+ */
+ editSchema(schema: MetadataSchema) {
+ this.getActiveSchema().pipe(take(1)).subscribe((activeSchema) => {
+ if (schema === activeSchema) {
+ this.registryService.cancelEditMetadataSchema();
+ } else {
+ this.registryService.editMetadataSchema(schema);
+ }
+ });
+ }
+
+ /**
+ * Checks whether the given metadata schema is active (being edited)
+ * @param schema
+ */
+ isActive(schema: MetadataSchema): Observable {
+ return this.getActiveSchema().pipe(
+ map((activeSchema) => schema === activeSchema)
+ );
+ }
+
+ /**
+ * Gets the active metadata schema (being edited)
+ */
+ getActiveSchema(): Observable {
+ return this.registryService.getActiveMetadataSchema();
+ }
+
+ /**
+ * Select a metadata schema within the list (checkbox)
+ * @param schema
+ * @param event
+ */
+ selectMetadataSchema(schema: MetadataSchema, event) {
+ event.target.checked ?
+ this.registryService.selectMetadataSchema(schema) :
+ this.registryService.deselectMetadataSchema(schema);
+ }
+
+ /**
+ * Checks whether a given metadata schema is selected in the list (checkbox)
+ * @param schema
+ */
+ isSelected(schema: MetadataSchema): Observable {
+ return this.registryService.getSelectedMetadataSchemas().pipe(
+ map((schemas) => schemas.find((selectedSchema) => selectedSchema === schema) != null)
+ );
+ }
+
+ /**
+ * Delete all the selected metadata schemas
+ */
+ deleteSchemas() {
+ this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe(
+ (schemas) => {
+ const tasks$ = [];
+ for (const schema of schemas) {
+ if (hasValue(schema.id)) {
+ tasks$.push(this.registryService.deleteMetadataSchema(schema.id));
+ }
+ }
+ zip(...tasks$).subscribe((responses: RestResponse[]) => {
+ const successResponses = responses.filter((response: RestResponse) => response.isSuccessful);
+ const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful);
+ if (successResponses.length > 0) {
+ this.showNotification(true, successResponses.length);
+ }
+ if (failedResponses.length > 0) {
+ this.showNotification(false, failedResponses.length);
+ }
+ this.registryService.deselectAllMetadataSchema();
+ this.registryService.cancelEditMetadataSchema();
+ this.forceUpdateSchemas();
+ });
+ }
+ )
+ }
+
+ /**
+ * Show notifications for an amount of deleted metadata schemas
+ * @param success Whether or not the notification should be a success message (error message when false)
+ * @param amount The amount of deleted metadata schemas
+ */
+ showNotification(success: boolean, amount: number) {
+ const prefix = 'admin.registries.schema.notification';
+ const suffix = success ? 'success' : 'failure';
+ const messages = observableCombineLatest(
+ this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
+ this.translateService.get(`${prefix}.deleted.${suffix}`, { amount: amount })
+ );
+ messages.subscribe(([head, content]) => {
+ if (success) {
+ this.notificationsService.success(head, content)
+ } else {
+ this.notificationsService.error(head, content)
+ }
+ });
+ }
}
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.effects.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.effects.ts
new file mode 100644
index 0000000000..32aa2d27d7
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.effects.ts
@@ -0,0 +1,35 @@
+/**
+ * Makes sure that if the user navigates to another route, the sidebar is collapsed
+ */
+import { Injectable } from '@angular/core';
+import { Actions, Effect, ofType } from '@ngrx/effects';
+import { filter, map, tap } from 'rxjs/operators';
+import { SearchSidebarCollapseAction } from '../../../+search-page/search-sidebar/search-sidebar.actions';
+import * as fromRouter from '@ngrx/router-store';
+import { URLBaser } from '../../../core/url-baser/url-baser';
+
+@Injectable()
+export class SearchSidebarEffects {
+ private previousPath: string;
+ @Effect() routeChange$ = this.actions$
+ .pipe(
+ ofType(fromRouter.ROUTER_NAVIGATION),
+ filter((action) => this.previousPath !== this.getBaseUrl(action)),
+ tap((action) => {
+ this.previousPath = this.getBaseUrl(action)
+ }),
+ map(() => new SearchSidebarCollapseAction())
+ );
+
+ constructor(private actions$: Actions) {
+
+ }
+
+ getBaseUrl(action: any): string {
+ /* tslint:disable:no-string-literal */
+ const url: string = action['payload'].routerState.url;
+ return new URLBaser(url).toString();
+ /* tslint:enable:no-string-literal */
+ }
+
+}
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts
new file mode 100644
index 0000000000..f335c880ae
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts
@@ -0,0 +1,111 @@
+import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
+import {
+ MetadataRegistryAction,
+ MetadataRegistryActionTypes,
+ MetadataRegistryDeselectFieldAction,
+ MetadataRegistryDeselectSchemaAction,
+ MetadataRegistryEditFieldAction,
+ MetadataRegistryEditSchemaAction,
+ MetadataRegistrySelectFieldAction,
+ MetadataRegistrySelectSchemaAction
+} from './metadata-registry.actions';
+import { MetadataField } from '../../../core/metadata/metadatafield.model';
+
+/**
+ * The auth state.
+ * @interface State
+ */
+export interface MetadataRegistryState {
+ editSchema: MetadataSchema;
+ selectedSchemas: MetadataSchema[];
+ editField: MetadataField;
+ selectedFields: MetadataField[];
+}
+
+/**
+ * The initial state.
+ */
+const initialState: MetadataRegistryState = {
+ editSchema: null,
+ selectedSchemas: [],
+ editField: null,
+ selectedFields: []
+};
+
+/**
+ * Reducer that handles MetadataRegistryActions to modify metadata schema and/or field states
+ * @param state The current MetadataRegistryState
+ * @param action The MetadataRegistryAction to perform on the state
+ */
+export function metadataRegistryReducer(state = initialState, action: MetadataRegistryAction): MetadataRegistryState {
+
+ switch (action.type) {
+
+ case MetadataRegistryActionTypes.EDIT_SCHEMA: {
+ return Object.assign({}, state, {
+ editSchema: (action as MetadataRegistryEditSchemaAction).schema
+ });
+ }
+
+ case MetadataRegistryActionTypes.CANCEL_EDIT_SCHEMA: {
+ return Object.assign({}, state, {
+ editSchema: null
+ });
+ }
+
+ case MetadataRegistryActionTypes.SELECT_SCHEMA: {
+ return Object.assign({}, state, {
+ selectedSchemas: [...state.selectedSchemas, (action as MetadataRegistrySelectSchemaAction).schema]
+ });
+ }
+
+ case MetadataRegistryActionTypes.DESELECT_SCHEMA: {
+ return Object.assign({}, state, {
+ selectedSchemas: state.selectedSchemas.filter(
+ (selectedSchema) => selectedSchema !== (action as MetadataRegistryDeselectSchemaAction).schema
+ )
+ });
+ }
+
+ case MetadataRegistryActionTypes.DESELECT_ALL_SCHEMA: {
+ return Object.assign({}, state, {
+ selectedSchemas: []
+ });
+ }
+
+ case MetadataRegistryActionTypes.EDIT_FIELD: {
+ return Object.assign({}, state, {
+ editField: (action as MetadataRegistryEditFieldAction).field
+ });
+ }
+
+ case MetadataRegistryActionTypes.CANCEL_EDIT_FIELD: {
+ return Object.assign({}, state, {
+ editField: null
+ });
+ }
+
+ case MetadataRegistryActionTypes.SELECT_FIELD: {
+ return Object.assign({}, state, {
+ selectedFields: [...state.selectedFields, (action as MetadataRegistrySelectFieldAction).field]
+ });
+ }
+
+ case MetadataRegistryActionTypes.DESELECT_FIELD: {
+ return Object.assign({}, state, {
+ selectedFields: state.selectedFields.filter(
+ (selectedField) => selectedField !== (action as MetadataRegistryDeselectFieldAction).field
+ )
+ });
+ }
+
+ case MetadataRegistryActionTypes.DESELECT_ALL_FIELD: {
+ return Object.assign({}, state, {
+ selectedFields: []
+ });
+ }
+
+ default:
+ return state;
+ }
+}
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html
new file mode 100644
index 0000000000..a4a4613565
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html
@@ -0,0 +1,18 @@
+
+
+
+ {{messagePrefix + '.create' | translate}}
+
+
+
+ {{messagePrefix + '.edit' | translate}}
+
+
+
+
+
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts
new file mode 100644
index 0000000000..02cf168387
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts
@@ -0,0 +1,105 @@
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
+
+import { MetadataSchemaFormComponent } from './metadata-schema-form.component';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+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 { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe';
+import { RegistryService } from '../../../../core/registry/registry.service';
+import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
+import { of as observableOf } from 'rxjs/internal/observable/of';
+import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
+
+describe('MetadataSchemaFormComponent', () => {
+ let component: MetadataSchemaFormComponent;
+ let fixture: ComponentFixture;
+ let registryService: RegistryService;
+
+ /* tslint:disable:no-empty */
+ const registryServiceStub = {
+ getActiveMetadataSchema: () => observableOf(undefined),
+ createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema)
+ };
+ const formBuilderServiceStub = {
+ createFormGroup: () => {
+ return {
+ patchValue: () => {}
+ };
+ }
+ };
+ /* tslint:enable:no-empty */
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
+ declarations: [ MetadataSchemaFormComponent, EnumKeysPipe ],
+ providers: [
+ { provide: RegistryService, useValue: registryServiceStub },
+ { provide: FormBuilderService, useValue: formBuilderServiceStub }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataSchemaFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ beforeEach(inject([RegistryService], (s) => {
+ registryService = s;
+ }));
+
+ describe('when submitting the form', () => {
+ const namespace = 'fake namespace';
+ const prefix = 'fake';
+
+ const expected = Object.assign(new MetadataSchema(), {
+ namespace: namespace,
+ prefix: prefix
+ });
+
+ beforeEach(() => {
+ spyOn(component.submitForm, 'emit');
+ component.name.value = prefix;
+ component.namespace.value = namespace;
+ });
+
+ describe('without an active schema', () => {
+ beforeEach(() => {
+ spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(undefined));
+ component.onSubmit();
+ fixture.detectChanges();
+ });
+
+ it('should emit a new schema using the correct values', async(() => {
+ fixture.whenStable().then(() => {
+ expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
+ });
+ }));
+ });
+
+ describe('with an active schema', () => {
+ const expectedWithId = Object.assign(new MetadataSchema(), {
+ id: 1,
+ namespace: namespace,
+ prefix: prefix
+ });
+
+ beforeEach(() => {
+ spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(expectedWithId));
+ component.onSubmit();
+ fixture.detectChanges();
+ });
+
+ it('should edit the existing schema using the correct values', async(() => {
+ fixture.whenStable().then(() => {
+ expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
+ });
+ }));
+ });
+ });
+});
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts
new file mode 100644
index 0000000000..23a5765058
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts
@@ -0,0 +1,171 @@
+import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
+import {
+ DynamicFormControlModel,
+ DynamicFormGroupModel,
+ DynamicFormLayout,
+ DynamicInputModel
+} from '@ng-dynamic-forms/core';
+import { FormGroup } from '@angular/forms';
+import { RegistryService } from '../../../../core/registry/registry.service';
+import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
+import { take } from 'rxjs/operators';
+import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
+import { TranslateService } from '@ngx-translate/core';
+import { combineLatest } from 'rxjs/internal/observable/combineLatest';
+
+@Component({
+ selector: 'ds-metadata-schema-form',
+ templateUrl: './metadata-schema-form.component.html'
+})
+/**
+ * A form used for creating and editing metadata schemas
+ */
+export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
+
+ /**
+ * A unique id used for ds-form
+ */
+ formId = 'metadata-schema-form';
+
+ /**
+ * The prefix for all messages related to this form
+ */
+ messagePrefix = 'admin.registries.metadata.form';
+
+ /**
+ * A dynamic input model for the name field
+ */
+ name: DynamicInputModel;
+
+ /**
+ * A dynamic input model for the namespace field
+ */
+ namespace: DynamicInputModel;
+
+ /**
+ * A list of all dynamic input models
+ */
+ formModel: DynamicFormControlModel[];
+
+ /**
+ * Layout used for structuring the form inputs
+ */
+ formLayout: DynamicFormLayout = {
+ name: {
+ grid: {
+ host: 'col col-sm-6 d-inline-block'
+ }
+ },
+ namespace: {
+ grid: {
+ host: 'col col-sm-6 d-inline-block'
+ }
+ }
+ };
+
+ /**
+ * A FormGroup that combines all inputs
+ */
+ formGroup: FormGroup;
+
+ /**
+ * An EventEmitter that's fired whenever the form is being submitted
+ */
+ @Output() submitForm: EventEmitter = new EventEmitter();
+
+ constructor(public registryService: RegistryService, private formBuilderService: FormBuilderService, private translateService: TranslateService) {
+ }
+
+ ngOnInit() {
+ combineLatest(
+ this.translateService.get(`${this.messagePrefix}.name`),
+ this.translateService.get(`${this.messagePrefix}.namespace`)
+ ).subscribe(([name, namespace]) => {
+ this.name = new DynamicInputModel({
+ id: 'name',
+ label: name,
+ name: 'name',
+ validators: {
+ required: null,
+ pattern: '^[^ ,_]{1,32}$'
+ },
+ required: true,
+ });
+ this.namespace = new DynamicInputModel({
+ id: 'namespace',
+ label: namespace,
+ name: 'namespace',
+ validators: {
+ required: null,
+ },
+ required: true,
+ });
+ this.formModel = [
+ this.namespace,
+ this.name
+ ];
+ this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
+ this.registryService.getActiveMetadataSchema().subscribe((schema) => {
+ this.formGroup.patchValue({
+ name: schema != null ? schema.prefix : '',
+ namespace: schema != null ? schema.namespace : ''
+ });
+ });
+ });
+ }
+
+ /**
+ * Stop editing the currently selected metadata schema
+ */
+ onCancel() {
+ this.registryService.cancelEditMetadataSchema();
+ }
+
+ /**
+ * Submit the form
+ * When the schema has an id attached -> Edit the schema
+ * When the schema has no id attached -> Create new schema
+ * Emit the updated/created schema using the EventEmitter submitForm
+ */
+ onSubmit() {
+ this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe(
+ (schema) => {
+ const values = {
+ prefix: this.name.value,
+ namespace: this.namespace.value
+ };
+ if (schema == null) {
+ this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), values)).subscribe((newSchema) => {
+ this.submitForm.emit(newSchema);
+ });
+ } else {
+ this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), {
+ id: schema.id,
+ prefix: (values.prefix ? values.prefix : schema.prefix),
+ namespace: (values.namespace ? values.namespace : schema.namespace)
+ })).subscribe((updatedSchema) => {
+ this.submitForm.emit(updatedSchema);
+ });
+ }
+ this.clearFields();
+ }
+ );
+ }
+
+ /**
+ * Reset all input-fields to be empty
+ */
+ clearFields() {
+ this.formGroup.patchValue({
+ prefix: '',
+ namespace: ''
+ });
+ }
+
+ /**
+ * Cancel the current edit when component is destroyed
+ */
+ ngOnDestroy(): void {
+ this.onCancel();
+ }
+}
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.html b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.html
new file mode 100644
index 0000000000..4e29fbb6e8
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.html
@@ -0,0 +1,18 @@
+
+
+
+ {{messagePrefix + '.create' | translate}}
+
+
+
+ {{messagePrefix + '.edit' | translate}}
+
+
+
+
+
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts
new file mode 100644
index 0000000000..25502a27c8
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts
@@ -0,0 +1,119 @@
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
+
+import { MetadataFieldFormComponent } from './metadata-field-form.component';
+import { RegistryService } from '../../../../core/registry/registry.service';
+import { of as observableOf } from 'rxjs/internal/observable/of';
+import { MetadataField } from '../../../../core/metadata/metadatafield.model';
+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 { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe';
+import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
+
+describe('MetadataFieldFormComponent', () => {
+ let component: MetadataFieldFormComponent;
+ let fixture: ComponentFixture;
+ let registryService: RegistryService;
+
+ const metadataSchema = Object.assign(new MetadataSchema(), {
+ id: 1,
+ namespace: 'fake schema',
+ prefix: 'fake'
+ });
+
+ /* tslint:disable:no-empty */
+ const registryServiceStub = {
+ getActiveMetadataField: () => observableOf(undefined),
+ createOrUpdateMetadataField: (field: MetadataField) => observableOf(field)
+ };
+ const formBuilderServiceStub = {
+ createFormGroup: () => {
+ return {
+ patchValue: () => {}
+ };
+ }
+ };
+ /* tslint:enable:no-empty */
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
+ declarations: [ MetadataFieldFormComponent, EnumKeysPipe ],
+ providers: [
+ { provide: RegistryService, useValue: registryServiceStub },
+ { provide: FormBuilderService, useValue: formBuilderServiceStub }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataFieldFormComponent);
+ component = fixture.componentInstance;
+ component.metadataSchema = metadataSchema;
+ fixture.detectChanges();
+ });
+
+ beforeEach(inject([RegistryService], (s) => {
+ registryService = s;
+ }));
+
+ describe('when submitting the form', () => {
+ const element = 'fakeElement';
+ const qualifier = 'fakeQualifier';
+ const scopeNote = 'fakeScopeNote';
+
+ const expected = Object.assign(new MetadataField(), {
+ schema: metadataSchema,
+ element: element,
+ qualifier: qualifier,
+ scopeNote: scopeNote
+ });
+
+ beforeEach(() => {
+ spyOn(component.submitForm, 'emit');
+ component.element.value = element;
+ component.qualifier.value = qualifier;
+ component.scopeNote.value = scopeNote;
+ });
+
+ describe('without an active field', () => {
+ beforeEach(() => {
+ spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(undefined));
+ component.onSubmit();
+ fixture.detectChanges();
+ });
+
+ it('should emit a new field using the correct values', async(() => {
+ fixture.whenStable().then(() => {
+ expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
+ });
+ }));
+ });
+
+ describe('with an active field', () => {
+ const expectedWithId = Object.assign(new MetadataField(), {
+ id: 1,
+ schema: metadataSchema,
+ element: element,
+ qualifier: qualifier,
+ scopeNote: scopeNote
+ });
+
+ beforeEach(() => {
+ spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(expectedWithId));
+ component.onSubmit();
+ fixture.detectChanges();
+ });
+
+ it('should edit the existing field using the correct values', async(() => {
+ fixture.whenStable().then(() => {
+ expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
+ });
+ }));
+ });
+ });
+});
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts
new file mode 100644
index 0000000000..4c09428bd5
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts
@@ -0,0 +1,203 @@
+import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
+import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
+import {
+ DynamicFormControlModel,
+ DynamicFormGroupModel,
+ DynamicFormLayout,
+ DynamicInputModel
+} from '@ng-dynamic-forms/core';
+import { FormGroup } from '@angular/forms';
+import { RegistryService } from '../../../../core/registry/registry.service';
+import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
+import { Observable } from 'rxjs/internal/Observable';
+import { MetadataField } from '../../../../core/metadata/metadatafield.model';
+import { take } from 'rxjs/operators';
+import { TranslateService } from '@ngx-translate/core';
+import { combineLatest } from 'rxjs/internal/observable/combineLatest';
+
+@Component({
+ selector: 'ds-metadata-field-form',
+ templateUrl: './metadata-field-form.component.html'
+})
+/**
+ * A form used for creating and editing metadata fields
+ */
+export class MetadataFieldFormComponent implements OnInit, OnDestroy {
+
+ /**
+ * A unique id used for ds-form
+ */
+ formId = 'metadata-field-form';
+
+ /**
+ * The prefix for all messages related to this form
+ */
+ messagePrefix = 'admin.registries.schema.form';
+
+ /**
+ * The metadata schema this field is attached to
+ */
+ @Input() metadataSchema: MetadataSchema;
+
+ /**
+ * A dynamic input model for the element field
+ */
+ element: DynamicInputModel;
+
+ /**
+ * A dynamic input model for the qualifier field
+ */
+ qualifier: DynamicInputModel;
+
+ /**
+ * A dynamic input model for the scopeNote field
+ */
+ scopeNote: DynamicInputModel;
+
+ /**
+ * A list of all dynamic input models
+ */
+ formModel: DynamicFormControlModel[];
+
+ /**
+ * Layout used for structuring the form inputs
+ */
+ formLayout: DynamicFormLayout = {
+ element: {
+ grid: {
+ host: 'col col-sm-6 d-inline-block'
+ }
+ },
+ qualifier: {
+ grid: {
+ host: 'col col-sm-6 d-inline-block'
+ }
+ },
+ scopeNote: {
+ grid: {
+ host: 'col col-sm-12 d-inline-block'
+ }
+ }
+ };
+
+ /**
+ * A FormGroup that combines all inputs
+ */
+ formGroup: FormGroup;
+
+ /**
+ * An EventEmitter that's fired whenever the form is being submitted
+ */
+ @Output() submitForm: EventEmitter = new EventEmitter();
+
+ constructor(public registryService: RegistryService,
+ private formBuilderService: FormBuilderService,
+ private translateService: TranslateService) {
+ }
+
+ /**
+ * Initialize the component, setting up the necessary Models for the dynamic form
+ */
+ ngOnInit() {
+ combineLatest(
+ this.translateService.get(`${this.messagePrefix}.element`),
+ this.translateService.get(`${this.messagePrefix}.qualifier`),
+ this.translateService.get(`${this.messagePrefix}.scopenote`)
+ ).subscribe(([element, qualifier, scopenote]) => {
+ this.element = new DynamicInputModel({
+ id: 'element',
+ label: element,
+ name: 'element',
+ validators: {
+ required: null,
+ },
+ required: true,
+ });
+ this.qualifier = new DynamicInputModel({
+ id: 'qualifier',
+ label: qualifier,
+ name: 'qualifier',
+ required: false,
+ });
+ this.scopeNote = new DynamicInputModel({
+ id: 'scopeNote',
+ label: scopenote,
+ name: 'scopeNote',
+ required: false,
+ });
+ this.formModel = [
+ this.element,
+ this.qualifier,
+ this.scopeNote
+ ];
+ this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
+ this.registryService.getActiveMetadataField().subscribe((field) => {
+ this.formGroup.patchValue({
+ element: field != null ? field.element : '',
+ qualifier: field != null ? field.qualifier : '',
+ scopeNote: field != null ? field.scopeNote : ''
+ });
+ });
+ });
+ }
+
+ /**
+ * Stop editing the currently selected metadata field
+ */
+ onCancel() {
+ this.registryService.cancelEditMetadataField();
+ }
+
+ /**
+ * Submit the form
+ * When the field has an id attached -> Edit the field
+ * When the field has no id attached -> Create new field
+ * Emit the updated/created field using the EventEmitter submitForm
+ */
+ onSubmit() {
+ this.registryService.getActiveMetadataField().pipe(take(1)).subscribe(
+ (field) => {
+ const values = {
+ schema: this.metadataSchema,
+ element: this.element.value,
+ qualifier: this.qualifier.value,
+ scopeNote: this.scopeNote.value
+ };
+ if (field == null) {
+ this.registryService.createOrUpdateMetadataField(Object.assign(new MetadataField(), values)).subscribe((newField) => {
+ this.submitForm.emit(newField);
+ });
+ } else {
+ this.registryService.createOrUpdateMetadataField(Object.assign(new MetadataField(), {
+ id: field.id,
+ schema: this.metadataSchema,
+ element: (values.element ? values.element : field.element),
+ qualifier: (values.qualifier ? values.qualifier : field.qualifier),
+ scopeNote: (values.scopeNote ? values.scopeNote : field.scopeNote)
+ })).subscribe((updatedField) => {
+ this.submitForm.emit(updatedField);
+ });
+ }
+ this.clearFields();
+ }
+ );
+ }
+
+ /**
+ * Reset all input-fields to be empty
+ */
+ clearFields() {
+ this.formGroup.patchValue({
+ element: '',
+ qualifier: '',
+ scopeNote: ''
+ });
+ }
+
+ /**
+ * Cancel the current edit when component is destroyed
+ */
+ ngOnDestroy(): void {
+ this.onCancel();
+ }
+}
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html
index e9734888ae..4a7a4cf34d 100644
--- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html
+++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html
@@ -6,36 +6,56 @@
{{'admin.registries.schema.description' | translate:namespace }}
+
+
{{'admin.registries.schema.fields.head' | translate}}
+
0"
[paginationOptions]="config"
[pageInfoState]="(metadataFields | async)?.payload"
[collectionSize]="(metadataFields | async)?.payload?.totalElements"
- [hideGear]="true"
+ [hideGear]="false"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
-
+
+
{{'admin.registries.schema.fields.no-items' | translate}}
+
+ {{'admin.registries.schema.return' | translate}}
+ 0" type="submit" class="btn btn-danger float-right" (click)="deleteFields()">{{'admin.registries.schema.fields.table.delete' | translate}}
+
+
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.scss b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.scss
new file mode 100644
index 0000000000..8c208ffad5
--- /dev/null
+++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.scss
@@ -0,0 +1,5 @@
+@import '../../../../styles/variables.scss';
+
+.selectable-row:hover {
+ cursor: pointer;
+}
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts
index 96777116f4..37fb51e5c7 100644
--- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts
+++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts
@@ -1,5 +1,5 @@
import { MetadataSchemaComponent } from './metadata-schema.component';
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
@@ -17,6 +17,10 @@ import { HostWindowService } from '../../../shared/host-window.service';
import { RouterStub } from '../../../shared/testing/router-stub';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
+import { RestResponse } from '../../../core/cache/response.models';
describe('MetadataSchemaComponent', () => {
let comp: MetadataSchemaComponent;
@@ -38,40 +42,53 @@ describe('MetadataSchemaComponent', () => {
];
const mockFieldsList = [
{
+ id: 1,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8',
element: 'contributor',
qualifier: 'advisor',
- scopenote: null,
+ scopeNote: null,
schema: mockSchemasList[0]
},
{
+ id: 2,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9',
element: 'contributor',
qualifier: 'author',
- scopenote: null,
+ scopeNote: null,
schema: mockSchemasList[0]
},
{
+ id: 3,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10',
element: 'contributor',
qualifier: 'editor',
- scopenote: 'test scope note',
+ scopeNote: 'test scope note',
schema: mockSchemasList[1]
},
{
+ id: 4,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11',
element: 'contributor',
qualifier: 'illustrator',
- scopenote: null,
+ scopeNote: null,
schema: mockSchemasList[1]
}
];
const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
+ /* tslint:disable:no-empty */
const registryServiceStub = {
getMetadataSchemas: () => mockSchemas,
getMetadataFieldsBySchema: (schema: MetadataSchema) => observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))),
- getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0]))
+ getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0])),
+ getActiveMetadataField: () => observableOf(undefined),
+ getSelectedMetadataFields: () => observableOf([]),
+ editMetadataField: (schema) => {},
+ cancelEditMetadataField: () => {},
+ deleteMetadataField: () => observableOf(new RestResponse(true, '200')),
+ deselectAllMetadataField: () => {},
+ clearMetadataFieldRequests: () => observableOf(undefined)
};
+ /* tslint:enable:no-empty */
const schemaNameParam = 'mock';
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
params: observableOf({
@@ -87,8 +104,10 @@ describe('MetadataSchemaComponent', () => {
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
- { provide: Router, useValue: new RouterStub() }
- ]
+ { provide: Router, useValue: new RouterStub() },
+ { provide: NotificationsService, useValue: new NotificationsServiceStub() }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
@@ -96,9 +115,12 @@ describe('MetadataSchemaComponent', () => {
fixture = TestBed.createComponent(MetadataSchemaComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
- registryService = (comp as any).service;
});
+ beforeEach(inject([RegistryService], (s) => {
+ registryService = s;
+ }));
+
it('should contain the schema prefix in the header', () => {
const header: HTMLElement = fixture.debugElement.query(By.css('.metadata-schema #header')).nativeElement;
expect(header.textContent).toContain('mock');
@@ -110,10 +132,54 @@ describe('MetadataSchemaComponent', () => {
});
it('should contain the correct fields', () => {
- const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(1)')).nativeElement;
+ const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(2)')).nativeElement;
expect(editorField.textContent).toBe('mock.contributor.editor');
- const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(1)')).nativeElement;
+ const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(2)')).nativeElement;
expect(illustratorField.textContent).toBe('mock.contributor.illustrator');
});
+
+ describe('when clicking a metadata field row', () => {
+ let row: HTMLElement;
+
+ beforeEach(() => {
+ spyOn(registryService, 'editMetadataField');
+ row = fixture.debugElement.query(By.css('.selectable-row')).nativeElement;
+ row.click();
+ fixture.detectChanges();
+ });
+
+ it('should start editing the selected field', async(() => {
+ fixture.whenStable().then(() => {
+ expect(registryService.editMetadataField).toHaveBeenCalledWith(mockFieldsList[2]);
+ });
+ }));
+
+ it('should cancel editing the selected field when clicked again', async(() => {
+ spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(mockFieldsList[2]));
+ spyOn(registryService, 'cancelEditMetadataField');
+ row.click();
+ fixture.detectChanges();
+ fixture.whenStable().then(() => {
+ expect(registryService.cancelEditMetadataField).toHaveBeenCalled();
+ });
+ }));
+ });
+
+ describe('when deleting metadata fields', () => {
+ const selectedFields = Array(mockFieldsList[2]);
+
+ beforeEach(() => {
+ spyOn(registryService, 'deleteMetadataField').and.callThrough();
+ spyOn(registryService, 'getSelectedMetadataFields').and.returnValue(observableOf(selectedFields));
+ comp.deleteFields();
+ fixture.detectChanges();
+ });
+
+ it('should call deleteMetadataField with the selected id', async(() => {
+ fixture.whenStable().then(() => {
+ expect(registryService.deleteMetadataField).toHaveBeenCalledWith(selectedFields[0].id);
+ });
+ }));
+ });
});
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts
index 6c5e01b37d..bdc7d5ed27 100644
--- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts
+++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts
@@ -1,29 +1,59 @@
import { Component, OnInit } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
-import { ActivatedRoute } from '@angular/router';
-import { Observable } from 'rxjs';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataField } from '../../../core/metadata/metadatafield.model';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
+import { map, take } from 'rxjs/operators';
+import { hasValue } from '../../../shared/empty.util';
+import { RestResponse } from '../../../core/cache/response.models';
+import { zip } from 'rxjs/internal/observable/zip';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-metadata-schema',
- templateUrl: './metadata-schema.component.html'
+ templateUrl: './metadata-schema.component.html',
+ styleUrls: ['./metadata-schema.component.scss']
})
+/**
+ * A component used for managing all existing metadata fields within the current metadata schema.
+ * The admin can create, edit or delete metadata fields here.
+ */
export class MetadataSchemaComponent implements OnInit {
+ /**
+ * The namespace of the metadata schema
+ */
namespace;
+ /**
+ * The metadata schema
+ */
metadataSchema: Observable>;
+
+ /**
+ * A list of all the fields attached to this metadata schema
+ */
metadataFields: Observable>>;
+
+ /**
+ * Pagination config used to display the list of metadata fields
+ */
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-metadatafields-pagination',
- pageSize: 10000
+ pageSize: 25,
+ pageSizeOptions: [25, 50, 100, 200]
});
- constructor(private registryService: RegistryService, private route: ActivatedRoute) {
+ constructor(private registryService: RegistryService,
+ private route: ActivatedRoute,
+ private notificationsService: NotificationsService,
+ private router: Router,
+ private translateService: TranslateService) {
}
@@ -33,22 +63,143 @@ export class MetadataSchemaComponent implements OnInit {
});
}
+ /**
+ * Initialize the component using the params within the url (schemaName)
+ * @param params
+ */
initialize(params) {
this.metadataSchema = this.registryService.getMetadataSchemaByName(params.schemaName);
this.updateFields();
}
+ /**
+ * Event triggered when the user changes page
+ * @param event
+ */
onPageChange(event) {
this.config.currentPage = event;
this.updateFields();
}
+ /**
+ * Update the list of fields by fetching it from the rest api or cache
+ */
private updateFields() {
this.metadataSchema.subscribe((schemaData) => {
const schema = schemaData.payload;
this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config);
- this.namespace = { namespace: schemaData.payload.namespace };
+ this.namespace = {namespace: schemaData.payload.namespace};
});
}
+ /**
+ * Force-update the list of fields by first clearing the cache related to metadata fields, then performing
+ * a new REST call
+ */
+ public forceUpdateFields() {
+ this.registryService.clearMetadataFieldRequests().subscribe();
+ this.updateFields();
+ }
+
+ /**
+ * Start editing the selected metadata field
+ * @param field
+ */
+ editField(field: MetadataField) {
+ this.getActiveField().pipe(take(1)).subscribe((activeField) => {
+ if (field === activeField) {
+ this.registryService.cancelEditMetadataField();
+ } else {
+ this.registryService.editMetadataField(field);
+ }
+ });
+ }
+
+ /**
+ * Checks whether the given metadata field is active (being edited)
+ * @param field
+ */
+ isActive(field: MetadataField): Observable {
+ return this.getActiveField().pipe(
+ map((activeField) => field === activeField)
+ );
+ }
+
+ /**
+ * Gets the active metadata field (being edited)
+ */
+ getActiveField(): Observable {
+ return this.registryService.getActiveMetadataField();
+ }
+
+ /**
+ * Select a metadata field within the list (checkbox)
+ * @param field
+ * @param event
+ */
+ selectMetadataField(field: MetadataField, event) {
+ event.target.checked ?
+ this.registryService.selectMetadataField(field) :
+ this.registryService.deselectMetadataField(field);
+ }
+
+ /**
+ * Checks whether a given metadata field is selected in the list (checkbox)
+ * @param field
+ */
+ isSelected(field: MetadataField): Observable {
+ return this.registryService.getSelectedMetadataFields().pipe(
+ map((fields) => fields.find((selectedField) => selectedField === field) != null)
+ );
+ }
+
+ /**
+ * Delete all the selected metadata fields
+ */
+ deleteFields() {
+ this.registryService.getSelectedMetadataFields().pipe(take(1)).subscribe(
+ (fields) => {
+ const tasks$ = [];
+ for (const field of fields) {
+ if (hasValue(field.id)) {
+ tasks$.push(this.registryService.deleteMetadataField(field.id));
+ }
+ }
+ zip(...tasks$).subscribe((responses: RestResponse[]) => {
+ const successResponses = responses.filter((response: RestResponse) => response.isSuccessful);
+ const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful);
+ if (successResponses.length > 0) {
+ this.showNotification(true, successResponses.length);
+ }
+ if (failedResponses.length > 0) {
+ this.showNotification(false, failedResponses.length);
+ }
+ this.registryService.deselectAllMetadataField();
+ this.registryService.cancelEditMetadataField();
+ this.forceUpdateFields();
+ });
+ }
+ )
+ }
+
+ /**
+ * Show notifications for an amount of deleted metadata fields
+ * @param success Whether or not the notification should be a success message (error message when false)
+ * @param amount The amount of deleted metadata fields
+ */
+ showNotification(success: boolean, amount: number) {
+ const prefix = 'admin.registries.schema.notification';
+ const suffix = success ? 'success' : 'failure';
+ const messages = observableCombineLatest(
+ this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
+ this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount })
+ );
+ messages.subscribe(([head, content]) => {
+ if (success) {
+ this.notificationsService.success(head, content)
+ } else {
+ this.notificationsService.error(head, content)
+ }
+ });
+ }
}
diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts
index e7c96bb9c4..71af51c683 100644
--- a/src/app/+admin/admin-routing.module.ts
+++ b/src/app/+admin/admin-routing.module.ts
@@ -4,7 +4,10 @@ import { NgModule } from '@angular/core';
@NgModule({
imports: [
RouterModule.forChild([
- { path: 'registries', loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' }
+ {
+ path: 'registries',
+ loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule'
+ }
])
]
})
diff --git a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts
index ace748c7de..3e6190ae6d 100644
--- a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts
+++ b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts
@@ -18,42 +18,38 @@ describe('SubCommunityList Component', () => {
const subcommunities = [Object.assign(new Community(), {
id: '123456789-1',
- metadata: [
- {
- key: 'dc.title',
- language: 'en_US',
- value: 'SubCommunity 1'
- }]
+ metadata: {
+ 'dc.title': [
+ { language: 'en_US', value: 'SubCommunity 1' }
+ ]
+ }
}),
Object.assign(new Community(), {
id: '123456789-2',
- metadata: [
- {
- key: 'dc.title',
- language: 'en_US',
- value: 'SubCommunity 2'
- }]
+ metadata: {
+ 'dc.title': [
+ { language: 'en_US', value: 'SubCommunity 2' }
+ ]
+ }
})
];
const emptySubCommunitiesCommunity = Object.assign(new Community(), {
- metadata: [
- {
- key: 'dc.title',
- language: 'en_US',
- value: 'Test title'
- }],
+ metadata: {
+ 'dc.title': [
+ { language: 'en_US', value: 'Test title' }
+ ]
+ },
subcommunities: observableOf(new RemoteData(true, true, true,
undefined, new PaginatedList(new PageInfo(), [])))
});
const mockCommunity = Object.assign(new Community(), {
- metadata: [
- {
- key: 'dc.title',
- language: 'en_US',
- value: 'Test title'
- }],
+ metadata: {
+ 'dc.title': [
+ { language: 'en_US', value: 'Test title' }
+ ]
+ },
subcommunities: observableOf(new RemoteData(true, true, true,
undefined, new PaginatedList(new PageInfo(), subcommunities)))
})
diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html
index d59d29ddbf..ce6e01df3d 100644
--- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html
+++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html
@@ -7,10 +7,12 @@
-
- {{metadatum.key}}
- {{metadatum.value}}
- {{metadatum.language}}
-
+
+
+ {{mdEntry.key}}
+ {{mdValue.value}}
+ {{mdValue.language}}
+
+
-
\ No newline at end of file
+
diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts
index 942357dc5a..07ad9a347c 100644
--- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts
+++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts
@@ -11,10 +11,14 @@ const mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
- metadata: [
- {key: 'dc.title', value: 'Mock item title', language: 'en'},
- {key: 'dc.contributor.author', value: 'Mayer, Ed', language: ''}
- ]
+ metadata: {
+ 'dc.title': [
+ { value: 'Mock item title', language: 'en' }
+ ],
+ 'dc.contributor.author': [
+ { value: 'Mayer, Ed', language: '' }
+ ]
+ }
});
describe('ModifyItemOverviewComponent', () => {
@@ -37,19 +41,19 @@ describe('ModifyItemOverviewComponent', () => {
const metadataRows = fixture.debugElement.queryAll(By.css('tr.metadata-row'));
expect(metadataRows.length).toEqual(2);
- const titleRow = metadataRows[0].queryAll(By.css('td'));
- expect(titleRow.length).toEqual(3);
-
- expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title');
- expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title');
- expect(titleRow[2].nativeElement.innerHTML).toContain('en');
-
- const authorRow = metadataRows[1].queryAll(By.css('td'));
+ const authorRow = metadataRows[0].queryAll(By.css('td'));
expect(authorRow.length).toEqual(3);
expect(authorRow[0].nativeElement.innerHTML).toContain('dc.contributor.author');
expect(authorRow[1].nativeElement.innerHTML).toContain('Mayer, Ed');
expect(authorRow[2].nativeElement.innerHTML).toEqual('');
+ const titleRow = metadataRows[1].queryAll(By.css('td'));
+ expect(titleRow.length).toEqual(3);
+
+ expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title');
+ expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title');
+ expect(titleRow[2].nativeElement.innerHTML).toContain('en');
+
});
});
diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts
index d32a98d5e0..282f8687e1 100644
--- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts
+++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts
@@ -1,6 +1,6 @@
import {Component, Input, OnInit} from '@angular/core';
import {Item} from '../../../core/shared/item.model';
-import {Metadatum} from '../../../core/shared/metadatum.model';
+import {MetadataMap} from '../../../core/shared/metadata.interfaces';
@Component({
selector: 'ds-modify-item-overview',
@@ -12,7 +12,7 @@ import {Metadatum} from '../../../core/shared/metadatum.model';
export class ModifyItemOverviewComponent implements OnInit {
@Input() item: Item;
- metadata: Metadatum[];
+ metadata: MetadataMap;
ngOnInit(): void {
this.metadata = this.item.metadata;
diff --git a/src/app/+item-page/field-components/collections/collections.component.spec.ts b/src/app/+item-page/field-components/collections/collections.component.spec.ts
index 865ce78a39..53fcded9e3 100644
--- a/src/app/+item-page/field-components/collections/collections.component.spec.ts
+++ b/src/app/+item-page/field-components/collections/collections.component.spec.ts
@@ -14,12 +14,14 @@ let collectionsComponent: CollectionsComponent;
let fixture: ComponentFixture;
const mockCollection1: Collection = Object.assign(new Collection(), {
- metadata: [
- {
- key: 'dc.description.abstract',
- language: 'en_US',
- value: 'Short description'
- }]
+ metadata: {
+ 'dc.description.abstract': [
+ {
+ language: 'en_US',
+ value: 'Short description'
+ }
+ ]
+ }
});
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: observableOf(new RemoteData(false, false, true, null, mockCollection1))});
diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html
index cc618bcd50..b5d7c118dd 100644
--- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html
+++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html
@@ -1,5 +1,5 @@
-
- {{ linktext || metadatum.value }}
+
+ {{ linktext || mdValue.value }}
diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts
index 212dcddee8..09d855e951 100644
--- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts
+++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts
@@ -1,6 +1,7 @@
import { Component, Input } from '@angular/core';
import { MetadataValuesComponent } from '../metadata-values/metadata-values.component';
+import { MetadataValue } from '../../../core/shared/metadata.interfaces';
/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link.
@@ -18,7 +19,7 @@ export class MetadataUriValuesComponent extends MetadataValuesComponent {
@Input() linktext: any;
- @Input() values: any;
+ @Input() mdValues: MetadataValue[];
@Input() separator: string;
diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.html b/src/app/+item-page/field-components/metadata-values/metadata-values.component.html
index f16655c63c..980c940255 100644
--- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.html
+++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.html
@@ -1,5 +1,5 @@
-
- {{metadatum.value}}
+
+ {{mdValue.value}}
diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts
index 1c94b56d57..708bdb49c7 100644
--- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts
+++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts
@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core';
-import { Metadatum } from '../../../core/shared/metadatum.model';
+import { MetadataValue } from '../../../core/shared/metadata.interfaces';
/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
@@ -12,7 +12,7 @@ import { Metadatum } from '../../../core/shared/metadatum.model';
})
export class MetadataValuesComponent {
- @Input() values: Metadatum[];
+ @Input() mdValues: MetadataValue[];
@Input() separator: string;
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
index d5a7febeb9..a68993cd16 100644
--- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
+++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
@@ -17,7 +17,7 @@
{{"item.page.filesection.description" | translate}}
- {{file.findMetadata("dc.description")}}
+ {{file.firstMetadataValue("dc.description")}}
diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html
index 1d0c4ab812..7aec57da0c 100644
--- a/src/app/+item-page/full/full-item-page.component.html
+++ b/src/app/+item-page/full/full-item-page.component.html
@@ -9,11 +9,13 @@
-
- {{metadatum.key}}
- {{metadatum.value}}
- {{metadatum.language}}
-
+
+
+ {{mdEntry.key}}
+ {{mdValue.value}}
+ {{mdValue.language}}
+
+
diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts
index d09ac268ec..fcb724b564 100644
--- a/src/app/+item-page/full/full-item-page.component.ts
+++ b/src/app/+item-page/full/full-item-page.component.ts
@@ -6,7 +6,7 @@ import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { ItemPageComponent } from '../simple/item-page.component';
-import { Metadatum } from '../../core/shared/metadatum.model';
+import { MetadataMap } from '../../core/shared/metadata.interfaces';
import { ItemDataService } from '../../core/data/item-data.service';
import { RemoteData } from '../../core/data/remote-data';
@@ -34,7 +34,7 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit {
itemRD$: Observable>;
- metadata$: Observable;
+ metadata$: Observable;
constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) {
super(route, items, metadataService);
diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html b/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html
index 4a27848ec6..d6a569198c 100644
--- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html
+++ b/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html
@@ -1,3 +1,3 @@
-
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html
index 4c53e2e3e2..aac85d335f 100644
--- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html
+++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html
@@ -1,3 +1,3 @@
-
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html
index fde79d6a04..a5561b22e5 100644
--- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html
+++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html
@@ -1,3 +1,3 @@
-
+
diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts
index 0683c74aed..3c1a46872c 100644
--- a/src/app/+search-page/normalized-search-result.model.ts
+++ b/src/app/+search-page/normalized-search-result.model.ts
@@ -1,5 +1,5 @@
import { autoserialize } from 'cerialize';
-import { Metadatum } from '../core/shared/metadatum.model';
+import { MetadataMap } from '../core/shared/metadata.interfaces';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
/**
@@ -16,6 +16,6 @@ export class NormalizedSearchResult implements ListableObject {
* The metadata that was used to find this item, hithighlighted
*/
@autoserialize
- hitHighlights: Metadatum[];
+ hitHighlights: MetadataMap;
}
diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts
index 00b1c62a99..b2e5eafdec 100644
--- a/src/app/+search-page/search-result.model.ts
+++ b/src/app/+search-page/search-result.model.ts
@@ -1,5 +1,5 @@
import { DSpaceObject } from '../core/shared/dspace-object.model';
-import { Metadatum } from '../core/shared/metadatum.model';
+import { MetadataMap } from '../core/shared/metadata.interfaces';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
/**
@@ -14,6 +14,6 @@ export class SearchResult implements ListableObject {
/**
* The metadata that was used to find this item, hithighlighted
*/
- hitHighlights: Metadatum[];
+ hitHighlights: MetadataMap;
}
diff --git a/src/app/+search-page/search-results/search-results.component.spec.ts b/src/app/+search-page/search-results/search-results.component.spec.ts
index b7ac11553a..8d0566d1df 100644
--- a/src/app/+search-page/search-results/search-results.component.spec.ts
+++ b/src/app/+search-page/search-results/search-results.component.spec.ts
@@ -111,33 +111,38 @@ export const objects = [
id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f',
uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f',
type: ResourceType.Community,
- metadata: [
- {
- key: 'dc.description',
- language: null,
- value: ''
- },
- {
- key: 'dc.description.abstract',
- language: null,
- value: 'This is a test community to hold content for the OR2017 demostration'
- },
- {
- key: 'dc.description.tableofcontents',
- language: null,
- value: ''
- },
- {
- key: 'dc.rights',
- language: null,
- value: ''
- },
- {
- key: 'dc.title',
- language: null,
- value: 'OR2017 - Demonstration'
- }
- ]
+ metadata: {
+ 'dc.description': [
+ {
+ language: null,
+ value: ''
+ }
+ ],
+ 'dc.description.abstract': [
+ {
+ language: null,
+ value: 'This is a test community to hold content for the OR2017 demostration'
+ }
+ ],
+ 'dc.description.tableofcontents': [
+ {
+ language: null,
+ value: ''
+ }
+ ],
+ 'dc.rights': [
+ {
+ language: null,
+ value: ''
+ }
+ ],
+ 'dc.title': [
+ {
+ language: null,
+ value: 'OR2017 - Demonstration'
+ }
+ ]
+ }
}),
Object.assign(new Community(),
{
@@ -160,33 +165,38 @@ export const objects = [
id: '9076bd16-e69a-48d6-9e41-0238cb40d863',
uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863',
type: ResourceType.Community,
- metadata: [
- {
- key: 'dc.description',
- language: null,
- value: 'This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).
\r\nDSpace Communities may contain one or more Sub-Communities or Collections (of Items).
\r\nThis particular Community has its own logo (the DuraSpace logo).
'
- },
- {
- key: 'dc.description.abstract',
- language: null,
- value: 'This is a sample top-level community'
- },
- {
- key: 'dc.description.tableofcontents',
- language: null,
- value: 'This is the news section for this Sample Community . System or Community Administrators (of this Community) can edit this News field.
'
- },
- {
- key: 'dc.rights',
- language: null,
- value: 'If this Community had special copyright text to display, it would be displayed here.
'
- },
- {
- key: 'dc.title',
- language: null,
- value: 'Sample Community'
- }
- ]
+ metadata: {
+ 'dc.description': [
+ {
+ language: null,
+ value: 'This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).
\r\nDSpace Communities may contain one or more Sub-Communities or Collections (of Items).
\r\nThis particular Community has its own logo (the DuraSpace logo).
'
+ }
+ ],
+ 'dc.description.abstract': [
+ {
+ language: null,
+ value: 'This is a sample top-level community'
+ }
+ ],
+ 'dc.description.tableofcontents': [
+ {
+ language: null,
+ value: 'This is the news section for this Sample Community . System or Community Administrators (of this Community) can edit this News field.
'
+ }
+ ],
+ 'dc.rights': [
+ {
+ language: null,
+ value: 'If this Community had special copyright text to display, it would be displayed here.
'
+ }
+ ],
+ 'dc.title': [
+ {
+ language: null,
+ value: 'Sample Community'
+ }
+ ]
+ }
}
)
];
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index fbb35bd624..03edd698fd 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
+import { AuthenticatedGuard } from './core/auth/authenticated.guard';
const ITEM_MODULE_PATH = 'items';
export function getItemModulePath() {
@@ -17,7 +18,7 @@ export function getItemModulePath() {
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ 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' },
+ { path: 'admin', 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.reducer.ts b/src/app/app.reducer.ts
index ed10dc3b78..ea2512a974 100644
--- a/src/app/app.reducer.ts
+++ b/src/app/app.reducer.ts
@@ -15,6 +15,10 @@ import {
NotificationsState
} from './shared/notifications/notifications.reducers';
import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
+import {
+ metadataRegistryReducer,
+ MetadataRegistryState
+} from './+admin/admin-registries/metadata-registry/metadata-registry.reducers';
import { hasValue } from './shared/empty.util';
import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer';
import { menusReducer, MenusState } from './shared/menu/menu.reducer';
@@ -25,6 +29,7 @@ export interface AppState {
history: HistoryState;
hostWindow: HostWindowState;
forms: FormState;
+ metadataRegistry: MetadataRegistryState;
notifications: NotificationsState;
searchSidebar: SearchSidebarState;
searchFilter: SearchFiltersState;
@@ -38,6 +43,7 @@ export const appReducers: ActionReducerMap = {
history: historyReducer,
hostWindow: hostWindowReducer,
forms: formReducer,
+ metadataRegistry: metadataRegistryReducer,
notifications: notificationsReducer,
searchSidebar: sidebarReducer,
searchFilter: filterReducer,
diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts
index df43ab9685..0b2c32fc04 100644
--- a/src/app/core/auth/auth-response-parsing.service.spec.ts
+++ b/src/app/core/auth/auth-response-parsing.service.spec.ts
@@ -78,23 +78,26 @@ describe('AuthResponseParsingService', () => {
handle: null,
id: '4dc70ab5-cd73-492f-b007-3179d2d9296b',
lastActive: '2018-05-14T17:03:31.277+0000',
- metadata: [
- {
- key: 'eperson.firstname',
- language: null,
- value: 'User'
- },
- {
- key: 'eperson.lastname',
- language: null,
- value: 'Test'
- },
- {
- key: 'eperson.language',
- language: null,
- value: 'en'
- }
- ],
+ metadata: {
+ 'eperson.firstname': [
+ {
+ language: null,
+ value: 'User'
+ }
+ ],
+ 'eperson.lastname': [
+ {
+ language: null,
+ value: 'Test'
+ }
+ ],
+ 'eperson.language': [
+ {
+ language: null,
+ value: 'en'
+ }
+ ]
+ },
name: 'User Test',
netid: 'myself@testshib.org',
requireCertificate: false,
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index 66fe65a22e..3390997a1e 100644
--- a/src/app/core/auth/auth.service.ts
+++ b/src/app/core/auth/auth.service.ts
@@ -1,4 +1,4 @@
-import { Observable, of as observableOf } from 'rxjs';
+import {Observable, of, of as observableOf} from 'rxjs';
import {
distinctUntilChanged,
filter,
diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts
index b9091a86ad..af0622cd19 100644
--- a/src/app/core/auth/authenticated.guard.ts
+++ b/src/app/core/auth/authenticated.guard.ts
@@ -3,7 +3,7 @@ import {take} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router';
-import { Observable } from 'rxjs';
+import {Observable, of} from 'rxjs';
import { select, Store } from '@ngrx/store';
// reducers
diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts
index f6ca489764..5a493592a8 100644
--- a/src/app/core/browse/browse.service.spec.ts
+++ b/src/app/core/browse/browse.service.spec.ts
@@ -220,44 +220,44 @@ describe('BrowseService', () => {
}}));
});
- it('should return the URL for the given metadatumKey and linkPath', () => {
- const metadatumKey = 'dc.date.issued';
+ it('should return the URL for the given metadataKey and linkPath', () => {
+ const metadataKey = 'dc.date.issued';
const linkPath = 'items';
const expectedURL = browseDefinitions[0]._links[linkPath];
- const result = service.getBrowseURLFor(metadatumKey, linkPath);
+ const result = service.getBrowseURLFor(metadataKey, linkPath);
const expected = cold('c-d-', { c: undefined, d: expectedURL });
expect(result).toBeObservable(expected);
});
- it('should work when the definition uses a wildcard in the metadatumKey', () => {
- const metadatumKey = 'dc.contributor.author'; // should match dc.contributor.* in the definition
+ it('should work when the definition uses a wildcard in the metadataKey', () => {
+ const metadataKey = 'dc.contributor.author'; // should match dc.contributor.* in the definition
const linkPath = 'items';
const expectedURL = browseDefinitions[1]._links[linkPath];
- const result = service.getBrowseURLFor(metadatumKey, linkPath);
+ const result = service.getBrowseURLFor(metadataKey, linkPath);
const expected = cold('c-d-', { c: undefined, d: expectedURL });
expect(result).toBeObservable(expected);
});
it('should throw an error when the key doesn\'t match', () => {
- const metadatumKey = 'dc.title'; // isn't in the definitions
+ const metadataKey = 'dc.title'; // isn't in the definitions
const linkPath = 'items';
- const result = service.getBrowseURLFor(metadatumKey, linkPath);
- const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`));
+ const result = service.getBrowseURLFor(metadataKey, linkPath);
+ const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`));
expect(result).toBeObservable(expected);
});
it('should throw an error when the link doesn\'t match', () => {
- const metadatumKey = 'dc.date.issued';
+ const metadataKey = 'dc.date.issued';
const linkPath = 'collections'; // isn't in the definitions
- const result = service.getBrowseURLFor(metadatumKey, linkPath);
- const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`));
+ const result = service.getBrowseURLFor(metadataKey, linkPath);
+ const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`));
expect(result).toBeObservable(expected);
});
@@ -272,10 +272,10 @@ describe('BrowseService', () => {
spyOn(service, 'getBrowseDefinitions').and
.returnValue(hot('----'));
- const metadatumKey = 'dc.date.issued';
+ const metadataKey = 'dc.date.issued';
const linkPath = 'items';
- const result = service.getBrowseURLFor(metadatumKey, linkPath);
+ const result = service.getBrowseURLFor(metadataKey, linkPath);
const expected = cold('b---', { b: undefined });
expect(result).toBeObservable(expected);
});
diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts
index e892024711..ef4fdaa5ff 100644
--- a/src/app/core/browse/browse.service.ts
+++ b/src/app/core/browse/browse.service.ts
@@ -35,12 +35,15 @@ import { Item } from '../shared/item.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
+/**
+ * Service that performs all actions that have to do with browse.
+ */
@Injectable()
export class BrowseService {
protected linkPath = 'browses';
- private static toSearchKeyArray(metadatumKey: string): string[] {
- const keyParts = metadatumKey.split('.');
+ private static toSearchKeyArray(metadataKey: string): string[] {
+ const keyParts = metadataKey.split('.');
const searchFor = [];
searchFor.push('*');
for (let i = 0; i < keyParts.length - 1; i++) {
@@ -48,7 +51,7 @@ export class BrowseService {
const nextPart = [...prevParts, '*'].join('.');
searchFor.push(nextPart);
}
- searchFor.push(metadatumKey);
+ searchFor.push(metadataKey);
return searchFor;
}
@@ -180,8 +183,8 @@ export class BrowseService {
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
- getBrowseURLFor(metadatumKey: string, linkPath: string): Observable {
- const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey);
+ getBrowseURLFor(metadataKey: string, linkPath: string): Observable {
+ const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey);
return this.getBrowseDefinitions().pipe(
getRemoteDataPayload(),
map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
@@ -192,7 +195,7 @@ export class BrowseService {
),
map((def: BrowseDefinition) => {
if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) {
- throw new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`);
+ throw new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`);
} else {
return def._links[linkPath];
}
diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts
index e4444ca803..272969050d 100644
--- a/src/app/core/cache/builders/remote-data-build.service.spec.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts
@@ -8,20 +8,24 @@ import { of as observableOf } from 'rxjs';
const pageInfo = new PageInfo();
const array = [
Object.assign(new Item(), {
- metadata: [
- {
- key: 'dc.title',
- language: 'en_US',
- value: 'Item nr 1'
- }]
+ metadata: {
+ 'dc.title': [
+ {
+ language: 'en_US',
+ value: 'Item nr 1'
+ }
+ ]
+ }
}),
Object.assign(new Item(), {
- metadata: [
- {
- key: 'dc.title',
- language: 'en_US',
- value: 'Item nr 2'
- }]
+ metadata: {
+ 'dc.title': [
+ {
+ language: 'en_US',
+ value: 'Item nr 2'
+ }
+ ]
+ }
})
];
const paginatedList = new PaginatedList(pageInfo, array);
diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts
index e4d700ff5e..48490e5ecb 100644
--- a/src/app/core/cache/builders/remote-data-build.service.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.ts
@@ -79,8 +79,8 @@ export class RemoteDataBuildService {
toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) {
return observableCombineLatest(requestEntry$, payload$).pipe(
map(([reqEntry, payload]) => {
- const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
- const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
+ const requestPending = hasValue(reqEntry) && hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
+ const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean;
let error: RemoteDataError;
if (hasValue(reqEntry) && hasValue(reqEntry.response)) {
diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts
index 030e17364a..87bd4b4369 100644
--- a/src/app/core/cache/models/normalized-dspace-object.model.ts
+++ b/src/app/core/cache/models/normalized-dspace-object.model.ts
@@ -1,7 +1,6 @@
import { autoserialize, autoserializeAs, deserialize, serialize } from 'cerialize';
import { DSpaceObject } from '../../shared/dspace-object.model';
-
-import { Metadatum } from '../../shared/metadatum.model';
+import { MetadataMap } from '../../shared/metadata.interfaces';
import { ResourceType } from '../../shared/resource-type';
import { mapsTo } from '../builders/build-decorators';
import { NormalizedObject } from './normalized-object.model';
@@ -46,10 +45,10 @@ export class NormalizedDSpaceObject extends NormalizedObject {
type: ResourceType;
/**
- * An array containing all metadata of this DSpaceObject
+ * All metadata of this DSpaceObject
*/
- @autoserializeAs(Metadatum)
- metadata: Metadatum[];
+ @autoserialize
+ metadata: MetadataMap;
/**
* An array of DSpaceObjects that are direct parents of this DSpaceObject
diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts
index 2f54bd45bf..786b5b3de3 100644
--- a/src/app/core/cache/models/normalized-object-factory.ts
+++ b/src/app/core/cache/models/normalized-object-factory.ts
@@ -16,6 +16,7 @@ import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model';
import { SubmissionDefinitionsModel } from '../../config/models/config-submission-definitions.model';
import { SubmissionFormsModel } from '../../config/models/config-submission-forms.model';
import { SubmissionSectionModel } from '../../config/models/config-submission-section.model';
+import { NormalizedMetadataSchema } from '../../metadata/normalized-metadata-schema.model';
export class NormalizedObjectFactory {
public static getConstructor(type: ResourceType): GenericConstructor {
@@ -35,6 +36,9 @@ export class NormalizedObjectFactory {
case ResourceType.Community: {
return NormalizedCommunity
}
+ case ResourceType.BitstreamFormat: {
+ return NormalizedBitstreamFormat
+ }
case ResourceType.License: {
return NormalizedLicense
}
@@ -50,6 +54,12 @@ export class NormalizedObjectFactory {
case ResourceType.Group: {
return NormalizedGroup
}
+ case ResourceType.MetadataSchema: {
+ return NormalizedMetadataSchema
+ }
+ case ResourceType.MetadataField: {
+ return NormalizedGroup
+ }
case ResourceType.Workflowitem: {
return NormalizedWorkflowItem
}
diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts
index bd3bafa568..5640ac54d2 100644
--- a/src/app/core/cache/response.models.ts
+++ b/src/app/core/cache/response.models.ts
@@ -6,13 +6,13 @@ import { FacetValue } from '../../+search-page/search-service/facet-value.model'
import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.model';
import { IntegrationModel } from '../integration/models/integration.model';
import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model';
-import { MetadataSchema } from '../metadata/metadataschema.model';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
import { AuthStatus } from '../auth/models/auth-status.model';
+import { MetadataSchema } from '../metadata/metadataschema.model';
+import { MetadataField } from '../metadata/metadatafield.model';
import { NormalizedObject } from './models/normalized-object.model';
import { PaginatedList } from '../data/paginated-list';
-import { DSpaceObject } from '../shared/dspace-object.model';
/* tslint:disable:max-classes-per-file */
export class RestResponse {
@@ -38,6 +38,9 @@ export class DSOSuccessResponse extends RestResponse {
}
}
+/**
+ * A successful response containing a list of MetadataSchemas wrapped in a RegistryMetadataschemasResponse
+ */
export class RegistryMetadataschemasSuccessResponse extends RestResponse {
constructor(
public metadataschemasResponse: RegistryMetadataschemasResponse,
@@ -49,6 +52,9 @@ export class RegistryMetadataschemasSuccessResponse extends RestResponse {
}
}
+/**
+ * A successful response containing a list of MetadataFields wrapped in a RegistryMetadatafieldsResponse
+ */
export class RegistryMetadatafieldsSuccessResponse extends RestResponse {
constructor(
public metadatafieldsResponse: RegistryMetadatafieldsResponse,
@@ -60,6 +66,9 @@ export class RegistryMetadatafieldsSuccessResponse extends RestResponse {
}
}
+/**
+ * A successful response containing a list of BitstreamFormats wrapped in a RegistryBitstreamformatsResponse
+ */
export class RegistryBitstreamformatsSuccessResponse extends RestResponse {
constructor(
public bitstreamformatsResponse: RegistryBitstreamformatsResponse,
@@ -71,6 +80,9 @@ export class RegistryBitstreamformatsSuccessResponse extends RestResponse {
}
}
+/**
+ * A successful response containing exactly one MetadataSchema
+ */
export class MetadataschemaSuccessResponse extends RestResponse {
constructor(
public metadataschema: MetadataSchema,
@@ -81,6 +93,19 @@ export class MetadataschemaSuccessResponse extends RestResponse {
}
}
+/**
+ * A successful response containing exactly one MetadataField
+ */
+export class MetadatafieldSuccessResponse extends RestResponse {
+ constructor(
+ public metadatafield: MetadataField,
+ public statusCode: number,
+ public statusText: string,
+ ) {
+ super(true, statusCode, statusText);
+ }
+}
+
export class SearchSuccessResponse extends RestResponse {
constructor(
public results: SearchQueryResponse,
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index f35c6d2e74..7106d28b3d 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -62,7 +62,6 @@ import { FacetValueMapResponseParsingService } from './data/facet-value-map-resp
import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service';
import { RegistryService } from './registry/registry.service';
import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service';
-import { MetadataschemaParsingService } from './data/metadataschema-parsing.service';
import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service';
import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service';
import { WorkflowitemDataService } from './submission/workflowitem-data.service';
@@ -72,6 +71,7 @@ import { FileService } from './shared/file.service';
import { SubmissionRestService } from '../submission/submission-rest.service';
import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service';
import { DSpaceObjectDataService } from './data/dspace-object-data.service';
+import { MetadataschemaParsingService } from './data/metadataschema-parsing.service';
import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
import { MenuService } from '../shared/menu/menu.service';
import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service';
@@ -126,7 +126,6 @@ const PROVIDERS = [
RegistryMetadataschemasResponseParsingService,
RegistryMetadatafieldsResponseParsingService,
RegistryBitstreamformatsResponseParsingService,
- MetadataschemaParsingService,
DebugResponseParsingService,
SearchResponseParsingService,
ServerResponseService,
@@ -145,6 +144,7 @@ const PROVIDERS = [
JsonPatchOperationsBuilder,
AuthorityService,
IntegrationResponseParsingService,
+ MetadataschemaParsingService,
UploaderService,
UUIDService,
NotificationsService,
diff --git a/src/app/core/data/browse-items-response-parsing-service.spec.ts b/src/app/core/data/browse-items-response-parsing-service.spec.ts
index 27568411b0..50b3be5de7 100644
--- a/src/app/core/data/browse-items-response-parsing-service.spec.ts
+++ b/src/app/core/data/browse-items-response-parsing-service.spec.ts
@@ -24,13 +24,14 @@ describe('BrowseItemsResponseParsingService', () => {
uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7',
name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India',
handle: '10986/17472',
- metadata: [
- {
- key: 'dc.creator',
- value: 'World Bank',
- language: null
- }
- ],
+ metadata: {
+ 'dc.creator': [
+ {
+ value: 'World Bank',
+ language: null
+ }
+ ]
+ },
inArchive: true,
discoverable: true,
withdrawn: false,
@@ -56,13 +57,14 @@ describe('BrowseItemsResponseParsingService', () => {
uuid: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b',
name: 'Development of Local Supply Chain : The Missing Link for Concentrated Solar Power Projects in India',
handle: '10986/17475',
- metadata: [
- {
- key: 'dc.creator',
- value: 'World Bank',
- language: null
- }
- ],
+ metadata: {
+ 'dc.creator': [
+ {
+ value: 'World Bank',
+ language: null
+ }
+ ]
+ },
inArchive: true,
discoverable: true,
withdrawn: false,
@@ -116,13 +118,14 @@ describe('BrowseItemsResponseParsingService', () => {
uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7',
name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India',
handle: '10986/17472',
- metadata: [
- {
- key: 'dc.creator',
- value: 'World Bank',
- language: null
- }
- ],
+ metadata: {
+ 'dc.creator': [
+ {
+ value: 'World Bank',
+ language: null
+ }
+ ]
+ },
inArchive: true,
discoverable: true,
withdrawn: false,
diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts
index 2de8eb9142..c98037624b 100644
--- a/src/app/core/data/item-data.service.ts
+++ b/src/app/core/data/item-data.service.ts
@@ -1,4 +1,3 @@
-
import {distinctUntilChanged, map, filter} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
@@ -14,14 +13,13 @@ import { URLCombiner } from '../url-combiner/url-combiner';
import { DataService } from './data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
-import { FindAllOptions, PatchRequest, RestRequest } from './request.models';
+import { DeleteRequest, FindAllOptions, PatchRequest, RestRequest } from './request.models';
import { ObjectCacheService } from '../cache/object-cache.service';
-import { NotificationsService } from '../../shared/notifications/notifications.service';
-import { HttpClient } from '@angular/common/http';
+import { configureRequest, getResponseFromEntry } from '../shared/operators';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
-import { configureRequest, getRequestFromRequestHref } from '../shared/operators';
-import { RequestEntry } from './request.reducer';
+import { HttpClient } from '@angular/common/http';
@Injectable()
export class ItemDataService extends DataService {
@@ -94,9 +92,7 @@ export class ItemDataService extends DataService {
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
),
configureRequest(this.requestService),
- map((request: RestRequest) => request.href),
- getRequestFromRequestHref(this.requestService),
- map((requestEntry: RequestEntry) => requestEntry.response)
+ getResponseFromEntry()
);
}
@@ -115,9 +111,7 @@ export class ItemDataService extends DataService {
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
),
configureRequest(this.requestService),
- map((request: RestRequest) => request.href),
- getRequestFromRequestHref(this.requestService),
- map((requestEntry: RequestEntry) => requestEntry.response)
+ getResponseFromEntry()
);
}
}
diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts
new file mode 100644
index 0000000000..f5a57ab978
--- /dev/null
+++ b/src/app/core/data/metadata-schema-data.service.ts
@@ -0,0 +1,51 @@
+import { Injectable } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { Observable } from 'rxjs';
+import { BrowseService } from '../browse/browse.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { CoreState } from '../core.reducers';
+
+import { DataService } from './data.service';
+import { RequestService } from './request.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { FindAllOptions, GetRequest, RestRequest } from './request.models';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { MetadataSchema } from '../metadata/metadataschema.model';
+import { NormalizedMetadataSchema } from '../metadata/normalized-metadata-schema.model';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { HttpClient } from '@angular/common/http';
+import { ChangeAnalyzer } from './change-analyzer';
+import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
+
+/**
+ * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint
+ */
+@Injectable()
+export class MetadataSchemaDataService extends DataService {
+ protected linkPath = 'metadataschemas';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ private bs: BrowseService,
+ protected halService: HALEndpointService,
+ protected objectCache: ObjectCacheService,
+ protected dataBuildService: NormalizedObjectBuildService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: ChangeAnalyzer) {
+ super();
+ }
+
+ /**
+ * Get the endpoint for browsing metadataschemas
+ * @param {FindAllOptions} options
+ * @returns {Observable}
+ */
+ public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable {
+
+ return null;
+ }
+
+}
diff --git a/src/app/core/data/metadatafield-parsing.service.ts b/src/app/core/data/metadatafield-parsing.service.ts
new file mode 100644
index 0000000000..86a3c8a925
--- /dev/null
+++ b/src/app/core/data/metadatafield-parsing.service.ts
@@ -0,0 +1,22 @@
+import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { RestRequest } from './request.models';
+import { ResponseParsingService } from './parsing.service';
+import { Injectable } from '@angular/core';
+import { MetadatafieldSuccessResponse, MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models';
+import { MetadataField } from '../metadata/metadatafield.model';
+
+/**
+ * A service responsible for parsing DSpaceRESTV2Response data related to a single MetadataField to a valid RestResponse
+ */
+@Injectable()
+export class MetadatafieldParsingService implements ResponseParsingService {
+
+ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
+ const payload = data.payload;
+
+ const deserialized = new DSpaceRESTv2Serializer(MetadataField).deserialize(payload);
+ return new MetadatafieldSuccessResponse(deserialized, data.statusCode);
+ }
+
+}
diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts
new file mode 100644
index 0000000000..6cc031f3c9
--- /dev/null
+++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts
@@ -0,0 +1,41 @@
+import { PageInfo } from '../shared/page-info.model';
+import { DSOResponseParsingService } from './dso-response-parsing.service';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import {
+ RegistryBitstreamformatsSuccessResponse
+} from '../cache/response.models';
+import { RegistryBitstreamformatsResponseParsingService } from './registry-bitstreamformats-response-parsing.service';
+
+describe('RegistryBitstreamformatsResponseParsingService', () => {
+ let service: RegistryBitstreamformatsResponseParsingService;
+
+ const mockDSOParser = Object.assign({
+ processPageInfo: () => new PageInfo()
+ }) as DSOResponseParsingService;
+
+ const data = Object.assign({
+ payload: {
+ _embedded: {
+ bitstreamformats: [
+ {
+ uuid: 'uuid-1',
+ description: 'a description'
+ },
+ {
+ uuid: 'uuid-2',
+ description: 'another description'
+ },
+ ]
+ }
+ }
+ }) as DSpaceRESTV2Response;
+
+ beforeEach(() => {
+ service = new RegistryBitstreamformatsResponseParsingService(mockDSOParser);
+ });
+
+ it('should parse the data correctly', () => {
+ const response = service.parse(null, data);
+ expect(response.constructor).toBe(RegistryBitstreamformatsSuccessResponse);
+ });
+});
diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts
new file mode 100644
index 0000000000..5ede21954a
--- /dev/null
+++ b/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts
@@ -0,0 +1,68 @@
+import { PageInfo } from '../shared/page-info.model';
+import { DSOResponseParsingService } from './dso-response-parsing.service';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import {
+ RegistryMetadatafieldsSuccessResponse
+} from '../cache/response.models';
+import { RegistryMetadatafieldsResponseParsingService } from './registry-metadatafields-response-parsing.service';
+
+describe('RegistryMetadatafieldsResponseParsingService', () => {
+ let service: RegistryMetadatafieldsResponseParsingService;
+
+ const mockDSOParser = Object.assign({
+ processPageInfo: () => new PageInfo()
+ }) as DSOResponseParsingService;
+
+ const data = Object.assign({
+ payload: {
+ _embedded: {
+ metadatafields: [
+ {
+ id: 1,
+ element: 'element',
+ qualifier: 'qualifier',
+ scopeNote: 'a scope note',
+ _embedded: {
+ schema: {
+ id: 1,
+ prefix: 'test',
+ namespace: 'test namespace'
+ }
+ }
+ },
+ {
+ id: 2,
+ element: 'secondelement',
+ qualifier: 'secondqualifier',
+ scopeNote: 'a second scope note',
+ _embedded: {
+ schema: {
+ id: 1,
+ prefix: 'test',
+ namespace: 'test namespace'
+ }
+ }
+ },
+ ]
+ }
+ }
+ }) as DSpaceRESTV2Response;
+
+ const emptyData = Object.assign({
+ payload: {}
+ }) as DSpaceRESTV2Response;
+
+ beforeEach(() => {
+ service = new RegistryMetadatafieldsResponseParsingService(mockDSOParser);
+ });
+
+ it('should parse the data correctly', () => {
+ const response = service.parse(null, data);
+ expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse);
+ });
+
+ it('should not produce an error and parse the data correctly when the data is empty', () => {
+ const response = service.parse(null, emptyData);
+ expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse);
+ });
+});
diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts
index 58e0422d93..a4bed3240e 100644
--- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts
+++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts
@@ -9,6 +9,7 @@ import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.seriali
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { Injectable } from '@angular/core';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
+import { hasValue } from '../../shared/empty.util';
@Injectable()
export class RegistryMetadatafieldsResponseParsingService implements ResponseParsingService {
@@ -18,15 +19,19 @@ export class RegistryMetadatafieldsResponseParsingService implements ResponsePar
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
- const metadatafields = payload._embedded.metadatafields;
- metadatafields.forEach((field) => {
- field.schema = field._embedded.schema;
- });
+ let metadatafields = [];
+
+ if (hasValue(payload._embedded)) {
+ metadatafields = payload._embedded.metadatafields;
+ metadatafields.forEach((field) => {
+ field.schema = field._embedded.schema;
+ });
+ }
payload.metadatafields = metadatafields;
const deserialized = new DSpaceRESTv2Serializer(RegistryMetadatafieldsResponse).deserialize(payload);
- return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload.page));
+ return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload));
}
}
diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts
new file mode 100644
index 0000000000..e49305d06a
--- /dev/null
+++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts
@@ -0,0 +1,50 @@
+import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service';
+import { PageInfo } from '../shared/page-info.model';
+import { DSOResponseParsingService } from './dso-response-parsing.service';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { RegistryMetadataschemasSuccessResponse } from '../cache/response.models';
+
+describe('RegistryMetadataschemasResponseParsingService', () => {
+ let service: RegistryMetadataschemasResponseParsingService;
+
+ const mockDSOParser = Object.assign({
+ processPageInfo: () => new PageInfo()
+ }) as DSOResponseParsingService;
+
+ const data = Object.assign({
+ payload: {
+ _embedded: {
+ metadataschemas: [
+ {
+ id: 1,
+ prefix: 'test',
+ namespace: 'test namespace'
+ },
+ {
+ id: 2,
+ prefix: 'second',
+ namespace: 'second test namespace'
+ }
+ ]
+ }
+ }
+ }) as DSpaceRESTV2Response;
+
+ const emptyData = Object.assign({
+ payload: {}
+ }) as DSpaceRESTV2Response;
+
+ beforeEach(() => {
+ service = new RegistryMetadataschemasResponseParsingService(mockDSOParser);
+ });
+
+ it('should parse the data correctly', () => {
+ const response = service.parse(null, data);
+ expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse);
+ });
+
+ it('should not produce an error and parse the data correctly when the data is empty', () => {
+ const response = service.parse(null, emptyData);
+ expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse);
+ });
+});
diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts
index 87990b6ee6..d19b334131 100644
--- a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts
+++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts
@@ -6,6 +6,7 @@ import { RegistryMetadataschemasResponse } from '../registry/registry-metadatasc
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { Injectable } from '@angular/core';
+import { hasValue } from '../../shared/empty.util';
@Injectable()
export class RegistryMetadataschemasResponseParsingService implements ResponseParsingService {
@@ -15,11 +16,14 @@ export class RegistryMetadataschemasResponseParsingService implements ResponsePa
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
- const metadataschemas = payload._embedded.metadataschemas;
+ let metadataschemas = [];
+ if (hasValue(payload._embedded)) {
+ metadataschemas = payload._embedded.metadataschemas;
+ }
payload.metadataschemas = metadataschemas;
const deserialized = new DSpaceRESTv2Serializer(RegistryMetadataschemasResponse).deserialize(payload);
- return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload.page));
+ return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload));
}
}
diff --git a/src/app/core/data/request.actions.ts b/src/app/core/data/request.actions.ts
index 28149c2ead..2b2de13504 100644
--- a/src/app/core/data/request.actions.ts
+++ b/src/app/core/data/request.actions.ts
@@ -10,7 +10,8 @@ export const RequestActionTypes = {
CONFIGURE: type('dspace/core/data/request/CONFIGURE'),
EXECUTE: type('dspace/core/data/request/EXECUTE'),
COMPLETE: type('dspace/core/data/request/COMPLETE'),
- RESET_TIMESTAMPS: type('dspace/core/data/request/RESET_TIMESTAMPS')
+ RESET_TIMESTAMPS: type('dspace/core/data/request/RESET_TIMESTAMPS'),
+ REMOVE: type('dspace/core/data/request/REMOVE')
};
/* tslint:disable:max-classes-per-file */
@@ -82,6 +83,24 @@ export class ResetResponseTimestampsAction implements Action {
}
}
+/**
+ * An ngrx action to remove a cached request
+ */
+export class RequestRemoveAction implements Action {
+ type = RequestActionTypes.REMOVE;
+ uuid: string;
+
+ /**
+ * Create a new RequestRemoveAction
+ *
+ * @param uuid
+ * the request's uuid
+ */
+ constructor(uuid: string) {
+ this.uuid = uuid
+ }
+}
+
/* tslint:enable:max-classes-per-file */
/**
@@ -91,4 +110,5 @@ export type RequestAction
= RequestConfigureAction
| RequestExecuteAction
| RequestCompleteAction
- | ResetResponseTimestampsAction;
+ | ResetResponseTimestampsAction
+ | RequestRemoveAction;
diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts
index b11b2c86f7..1afd24962c 100644
--- a/src/app/core/data/request.models.ts
+++ b/src/app/core/data/request.models.ts
@@ -14,6 +14,9 @@ import { RestRequestMethod } from './rest-request-method';
import { SearchParam } from '../cache/models/search-param.model';
import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service';
import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service';
+import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service';
+import { MetadataschemaParsingService } from './metadataschema-parsing.service';
+import { MetadatafieldParsingService } from './metadatafield-parsing.service';
/* tslint:disable:max-classes-per-file */
@@ -221,6 +224,58 @@ export class IntegrationRequest extends GetRequest {
}
}
+/**
+ * Request to create a MetadataSchema
+ */
+export class CreateMetadataSchemaRequest extends PostRequest {
+ constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
+ super(uuid, href, body, options);
+ }
+
+ getResponseParser(): GenericConstructor {
+ return MetadataschemaParsingService;
+ }
+}
+
+/**
+ * Request to update a MetadataSchema
+ */
+export class UpdateMetadataSchemaRequest extends PutRequest {
+ constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
+ super(uuid, href, body, options);
+ }
+
+ getResponseParser(): GenericConstructor {
+ return MetadataschemaParsingService;
+ }
+}
+
+/**
+ * Request to create a MetadataField
+ */
+export class CreateMetadataFieldRequest extends PostRequest {
+ constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
+ super(uuid, href, body, options);
+ }
+
+ getResponseParser(): GenericConstructor {
+ return MetadatafieldParsingService;
+ }
+}
+
+/**
+ * Request to update a MetadataField
+ */
+export class UpdateMetadataFieldRequest extends PutRequest {
+ constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
+ super(uuid, href, body, options);
+ }
+
+ getResponseParser(): GenericConstructor {
+ return MetadatafieldParsingService;
+ }
+}
+
/**
* Class representing a submission HTTP GET request object
*/
diff --git a/src/app/core/data/request.reducer.spec.ts b/src/app/core/data/request.reducer.spec.ts
index 4a04ec5ae2..65a4ddba17 100644
--- a/src/app/core/data/request.reducer.spec.ts
+++ b/src/app/core/data/request.reducer.spec.ts
@@ -4,7 +4,7 @@ import { requestReducer, RequestState } from './request.reducer';
import {
RequestCompleteAction,
RequestConfigureAction,
- RequestExecuteAction, ResetResponseTimestampsAction
+ RequestExecuteAction, RequestRemoveAction, ResetResponseTimestampsAction
} from './request.actions';
import { GetRequest } from './request.models';
import { RestResponse } from '../cache/response.models';
@@ -110,4 +110,13 @@ describe('requestReducer', () => {
expect(newState[id1].response.statusCode).toEqual(response.statusCode);
expect(newState[id1].response.timeAdded).toBe(timeStamp);
});
+
+ it('should remove the correct request, in response to a REMOVE action', () => {
+ const state = testState;
+
+ const action = new RequestRemoveAction(id1);
+ const newState = requestReducer(state, action);
+
+ expect(newState[id1]).toBeUndefined();
+ });
});
diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts
index 27b5a4188a..e324e4d5a2 100644
--- a/src/app/core/data/request.reducer.ts
+++ b/src/app/core/data/request.reducer.ts
@@ -1,6 +1,6 @@
import {
RequestActionTypes, RequestAction, RequestConfigureAction,
- RequestExecuteAction, RequestCompleteAction, ResetResponseTimestampsAction
+ RequestExecuteAction, RequestCompleteAction, ResetResponseTimestampsAction, RequestRemoveAction
} from './request.actions';
import { RestRequest } from './request.models';
import { RestResponse } from '../cache/response.models';
@@ -38,6 +38,10 @@ export function requestReducer(state = initialState, action: RequestAction): Req
return resetResponseTimestamps(state, action as ResetResponseTimestampsAction);
}
+ case RequestActionTypes.REMOVE: {
+ return removeRequest(state, action as RequestRemoveAction);
+ }
+
default: {
return state;
}
@@ -105,3 +109,18 @@ function resetResponseTimestamps(state: RequestState, action: ResetResponseTimes
});
return newState;
}
+
+/**
+ * Remove a request from the RequestState
+ * @param state The current RequestState
+ * @param action The RequestRemoveAction to perform
+ */
+function removeRequest(state: RequestState, action: RequestRemoveAction): RequestState {
+ const newState = Object.create(null);
+ for (const value in state) {
+ if (value !== action.uuid) {
+ newState[value] = state[value];
+ }
+ }
+ return newState;
+}
diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts
index 54190e628c..28b340056b 100644
--- a/src/app/core/data/request.service.spec.ts
+++ b/src/app/core/data/request.service.spec.ts
@@ -21,6 +21,8 @@ import {
import { RequestService } from './request.service';
import { TestScheduler } from 'rxjs/testing';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import { MockStore } from '../../shared/testing/mock-store';
+import { IndexState } from '../index/index.reducer';
describe('RequestService', () => {
let scheduler: TestScheduler;
@@ -59,7 +61,8 @@ describe('RequestService', () => {
service = new RequestService(
objectCache,
uuidService,
- store
+ store,
+ undefined
);
serviceAsAny = service as any;
});
diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts
index 1ba38f4486..5c5f0880e0 100644
--- a/src/app/core/data/request.service.ts
+++ b/src/app/core/data/request.service.ts
@@ -1,24 +1,24 @@
import { Injectable } from '@angular/core';
+import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { merge as observableMerge, Observable, of as observableOf, race as observableRace } from 'rxjs';
import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
-import { MemoizedSelector, select, Store } from '@ngrx/store';
-import { hasNoValue, hasValue } from '../../shared/empty.util';
+import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { CacheableObject } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
import { coreSelector, CoreState } from '../core.reducers';
-import { IndexName } from '../index/index.reducer';
+import { IndexName, IndexState } from '../index/index.reducer';
import { pathSelector } from '../shared/selectors';
import { UUIDService } from '../shared/uuid.service';
-import { RequestConfigureAction, RequestExecuteAction } from './request.actions';
+import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions';
import { GetRequest, RestRequest } from './request.models';
import { RequestEntry } from './request.reducer';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method';
import { getResponseFromEntry } from '../shared/operators';
-import { AddToIndexAction } from '../index/index.actions';
+import { AddToIndexAction, RemoveFromIndexBySubstringAction } from '../index/index.actions';
@Injectable()
export class RequestService {
@@ -26,7 +26,8 @@ export class RequestService {
constructor(private objectCache: ObjectCacheService,
private uuidService: UUIDService,
- private store: Store) {
+ private store: Store,
+ private indexStore: Store) {
}
private entryFromUUIDSelector(uuid: string): MemoizedSelector {
@@ -41,6 +42,38 @@ export class RequestService {
return pathSelector(coreSelector, 'index', IndexName.UUID_MAPPING, uuid);
}
+ /**
+ * Create a selector that fetches a list of request UUIDs from a given index substate of which the request href
+ * contains a given substring
+ * @param selector MemoizedSelector to start from
+ * @param name The name of the index substate we're fetching request UUIDs from
+ * @param href Substring that the request's href should contain
+ */
+ private uuidsFromHrefSubstringSelector(selector: MemoizedSelector, name: string, href: string): MemoizedSelector {
+ return createSelector(selector, (state: IndexState) => this.getUuidsFromHrefSubstring(state, name, href));
+ }
+
+ /**
+ * Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring
+ * @param state The IndexState
+ * @param name The name of the index substate we're fetching request UUIDs from
+ * @param href Substring that the request's href should contain
+ */
+ private getUuidsFromHrefSubstring(state: IndexState, name: string, href: string): string[] {
+ let result = [];
+ if (isNotEmpty(state)) {
+ const subState = state[name];
+ if (isNotEmpty(subState)) {
+ for (const value in subState) {
+ if (value.indexOf(href) > -1) {
+ result = [...result, subState[value]];
+ }
+ }
+ }
+ }
+ return result;
+ }
+
generateRequestId(): string {
return `client/${this.uuidService.generate()}`;
}
@@ -68,7 +101,6 @@ export class RequestService {
this.store.pipe(
select(this.originalUUIDFromUUIDSelector(uuid)),
mergeMap((originalUUID) => {
- console.log(originalUUID);
return this.store.pipe(select(this.entryFromUUIDSelector(originalUUID)))
},
))
@@ -107,6 +139,32 @@ export class RequestService {
}
}
+ /**
+ * Remove all request cache providing (part of) the href
+ * This also includes href-to-uuid index cache
+ * @param href A substring of the request(s) href
+ */
+ removeByHrefSubstring(href: string) {
+ this.store.pipe(
+ select(this.uuidsFromHrefSubstringSelector(pathSelector(coreSelector, 'index'), IndexName.REQUEST, href)),
+ take(1)
+ ).subscribe((uuids: string[]) => {
+ for (const uuid of uuids) {
+ this.removeByUuid(uuid);
+ }
+ });
+ this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((reqHref: string) => reqHref.indexOf(href) < 0);
+ this.indexStore.dispatch(new RemoveFromIndexBySubstringAction(IndexName.REQUEST, href));
+ }
+
+ /**
+ * Remove request cache using the request's UUID
+ * @param uuid
+ */
+ removeByUuid(uuid: string) {
+ this.store.dispatch(new RequestRemoveAction(uuid));
+ }
+
/**
* Check if a request is in the cache or if it's still pending
* @param {GetRequest} request The request to check
diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts
index 1fee9f40b5..0e6ac0fd05 100644
--- a/src/app/core/data/search-response-parsing.service.ts
+++ b/src/app/core/data/search-response-parsing.service.ts
@@ -7,7 +7,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { hasValue } from '../../shared/empty.util';
import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model';
-import { Metadatum } from '../shared/metadatum.model';
+import { MetadataMap, MetadataValue } from '../shared/metadata.interfaces';
@Injectable()
export class SearchResponseParsingService implements ResponseParsingService {
@@ -16,17 +16,17 @@ export class SearchResponseParsingService implements ResponseParsingService {
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload._embedded.searchResult;
- const hitHighlights = payload._embedded.objects
+ const hitHighlights: MetadataMap[] = payload._embedded.objects
.map((object) => object.hitHighlights)
.map((hhObject) => {
+ const mdMap: MetadataMap = {};
if (hhObject) {
- return Object.keys(hhObject).map((key) => Object.assign(new Metadatum(), {
- key: key,
- value: hhObject[key].join('...')
- }))
- } else {
- return [];
+ for (const key of Object.keys(hhObject)) {
+ const value: MetadataValue = { value: hhObject[key].join('...'), language: null };
+ mdMap[key] = [ value ];
+ }
}
+ return mdMap;
});
const dsoSelfLinks = payload._embedded.objects
diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
index e1116e8990..a2a9f2530c 100644
--- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
+++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
@@ -91,9 +91,9 @@ export class DSpaceRESTv2Service {
const form: FormData = new FormData();
form.append('name', dso.name);
if (dso.metadata) {
- for (const i of Object.keys(dso.metadata)) {
- if (isNotEmpty(dso.metadata[i].value)) {
- form.append(dso.metadata[i].key, dso.metadata[i].value);
+ for (const key of Object.keys(dso.metadata)) {
+ for (const value of dso.allMetadataValues(key)) {
+ form.append(key, value);
}
}
}
diff --git a/src/app/core/index/index.actions.ts b/src/app/core/index/index.actions.ts
index 014b6561a3..98d07d59d5 100644
--- a/src/app/core/index/index.actions.ts
+++ b/src/app/core/index/index.actions.ts
@@ -8,7 +8,8 @@ import { IndexName } from './index.reducer';
*/
export const IndexActionTypes = {
ADD: type('dspace/core/index/ADD'),
- REMOVE_BY_VALUE: type('dspace/core/index/REMOVE_BY_VALUE')
+ REMOVE_BY_VALUE: type('dspace/core/index/REMOVE_BY_VALUE'),
+ REMOVE_BY_SUBSTRING: type('dspace/core/index/REMOVE_BY_SUBSTRING')
};
/* tslint:disable:max-classes-per-file */
@@ -60,6 +61,30 @@ export class RemoveFromIndexByValueAction implements Action {
this.payload = { name, value };
}
+}
+
+/**
+ * An ngrx action to remove multiple values from the index by substring
+ */
+export class RemoveFromIndexBySubstringAction implements Action {
+ type = IndexActionTypes.REMOVE_BY_SUBSTRING;
+ payload: {
+ name: IndexName,
+ value: string
+ };
+
+ /**
+ * Create a new RemoveFromIndexByValueAction
+ *
+ * @param name
+ * the name of the index to remove from
+ * @param value
+ * the value to remove the UUID for
+ */
+ constructor(name: IndexName, value: string) {
+ this.payload = { name, value };
+ }
+
}
/* tslint:enable:max-classes-per-file */
diff --git a/src/app/core/index/index.reducer.spec.ts b/src/app/core/index/index.reducer.spec.ts
index ffc2c9fadc..d1403ac5bf 100644
--- a/src/app/core/index/index.reducer.spec.ts
+++ b/src/app/core/index/index.reducer.spec.ts
@@ -1,7 +1,7 @@
import * as deepFreeze from 'deep-freeze';
import { IndexName, indexReducer, IndexState } from './index.reducer';
-import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions';
+import { AddToIndexAction, RemoveFromIndexBySubstringAction, RemoveFromIndexByValueAction } from './index.actions';
class NullAction extends AddToIndexAction {
type = null;
@@ -59,4 +59,13 @@ describe('requestReducer', () => {
expect(newState[IndexName.OBJECT][key1]).toBeUndefined();
});
+
+ it('should remove the given \'value\' from its corresponding \'key\' in the correct substate, in response to a REMOVE_BY_SUBSTRING action', () => {
+ const state = testState;
+
+ const action = new RemoveFromIndexBySubstringAction(IndexName.OBJECT, key1);
+ const newState = indexReducer(state, action);
+
+ expect(newState[IndexName.OBJECT][key1]).toBeUndefined();
+ });
});
diff --git a/src/app/core/index/index.reducer.ts b/src/app/core/index/index.reducer.ts
index c179182509..3597c786d8 100644
--- a/src/app/core/index/index.reducer.ts
+++ b/src/app/core/index/index.reducer.ts
@@ -2,7 +2,7 @@ import {
IndexAction,
IndexActionTypes,
AddToIndexAction,
- RemoveFromIndexByValueAction
+ RemoveFromIndexByValueAction, RemoveFromIndexBySubstringAction
} from './index.actions';
export enum IndexName {
@@ -31,6 +31,10 @@ export function indexReducer(state = initialState, action: IndexAction): IndexSt
return removeFromIndexByValue(state, action as RemoveFromIndexByValueAction)
}
+ case IndexActionTypes.REMOVE_BY_SUBSTRING: {
+ return removeFromIndexBySubstring(state, action as RemoveFromIndexBySubstringAction)
+ }
+
default: {
return state;
}
@@ -60,3 +64,21 @@ function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValu
[action.payload.name]: newSubState
});
}
+
+/**
+ * Remove values from the IndexState's substate that contain a given substring
+ * @param state The IndexState to remove values from
+ * @param action The RemoveFromIndexByValueAction containing the necessary information to remove the values
+ */
+function removeFromIndexBySubstring(state: IndexState, action: RemoveFromIndexByValueAction): IndexState {
+ const subState = state[action.payload.name];
+ const newSubState = Object.create(null);
+ for (const value in subState) {
+ if (value.indexOf(action.payload.value) < 0) {
+ newSubState[value] = subState[value];
+ }
+ }
+ return Object.assign({}, state, {
+ [action.payload.name]: newSubState
+ });
+}
diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts
index ffef1b6f51..d6aec339a1 100644
--- a/src/app/core/metadata/metadata.service.spec.ts
+++ b/src/app/core/metadata/metadata.service.spec.ts
@@ -37,6 +37,7 @@ import { HttpClient } from '@angular/common/http';
import { EmptyError } from 'rxjs/internal-compatibility';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
+import { MetadataValue } from '../shared/metadata.interfaces';
/* tslint:disable:max-classes-per-file */
@Component({
@@ -88,7 +89,7 @@ describe('MetadataService', () => {
objectCacheService = new ObjectCacheService(store);
uuidService = new UUIDService();
- requestService = new RequestService(objectCacheService, uuidService, store);
+ requestService = new RequestService(objectCacheService, uuidService, store, undefined);
remoteDataBuildService = new RemoteDataBuildService(objectCacheService, requestService);
TestBed.configureTestingModule({
@@ -152,7 +153,7 @@ describe('MetadataService', () => {
expect(title.getTitle()).toEqual('Test PowerPoint Document');
expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document');
expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane');
- expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26T19:58:25Z');
+ expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26');
expect(tagStore.get('citation_issn')[0].content).toEqual('123456789');
expect(tagStore.get('citation_language')[0].content).toEqual('en');
expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3');
@@ -216,22 +217,18 @@ describe('MetadataService', () => {
const mockType = (mockItem: Item, type: string): Item => {
const typedMockItem = Object.assign(new Item(), mockItem) as Item;
- for (const metadatum of typedMockItem.metadata) {
- if (metadatum.key === 'dc.type') {
- metadatum.value = type;
- break;
- }
- }
+ typedMockItem.metadata['dc.type'] = [ { value: type } ] as MetadataValue[];
return typedMockItem;
};
const mockPublisher = (mockItem: Item): Item => {
const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
- publishedMockItem.metadata.push({
- key: 'dc.publisher',
- language: 'en_US',
- value: 'Mock Publisher'
- });
+ publishedMockItem.metadata['dc.publisher'] = [
+ {
+ language: 'en_US',
+ value: 'Mock Publisher'
+ }
+ ] as MetadataValue[];
return publishedMockItem;
}
diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts
index 9dbfae3f90..736bf11923 100644
--- a/src/app/core/metadata/metadata.service.ts
+++ b/src/app/core/metadata/metadata.service.ts
@@ -294,6 +294,10 @@ export class MetadataService {
}
}
+ private hasType(value: string): boolean {
+ return this.currentObject.value.hasMetadata('dc.type', { value: value, ignoreCase: true });
+ }
+
/**
* Returns true if this._item is a dissertation
*
@@ -301,14 +305,7 @@ export class MetadataService {
* true if this._item has a dc.type equal to 'Thesis'
*/
private isDissertation(): boolean {
- let isDissertation = false;
- for (const metadatum of this.currentObject.value.metadata) {
- if (metadatum.key === 'dc.type') {
- isDissertation = metadatum.value.toLowerCase() === 'thesis';
- break;
- }
- }
- return isDissertation;
+ return this.hasType('thesis');
}
/**
@@ -318,40 +315,15 @@ export class MetadataService {
* true if this._item has a dc.type equal to 'Technical Report'
*/
private isTechReport(): boolean {
- let isTechReport = false;
- for (const metadatum of this.currentObject.value.metadata) {
- if (metadatum.key === 'dc.type') {
- isTechReport = metadatum.value.toLowerCase() === 'technical report';
- break;
- }
- }
- return isTechReport;
+ return this.hasType('technical report');
}
private getMetaTagValue(key: string): string {
- let value: string;
- for (const metadatum of this.currentObject.value.metadata) {
- if (metadatum.key === key) {
- value = metadatum.value;
- }
- }
- return value;
+ return this.currentObject.value.firstMetadataValue(key);
}
private getFirstMetaTagValue(keys: string[]): string {
- let value: string;
- for (const metadatum of this.currentObject.value.metadata) {
- for (const key of keys) {
- if (key === metadatum.key) {
- value = metadatum.value;
- break;
- }
- }
- if (value !== undefined) {
- break;
- }
- }
- return value;
+ return this.currentObject.value.firstMetadataValue(keys);
}
private getMetaTagValuesAndCombine(key: string): string {
@@ -359,15 +331,7 @@ export class MetadataService {
}
private getMetaTagValues(keys: string[]): string[] {
- const values: string[] = [];
- for (const metadatum of this.currentObject.value.metadata) {
- for (const key of keys) {
- if (key === metadatum.key) {
- values.push(metadatum.value);
- }
- }
- }
- return values;
+ return this.currentObject.value.allMetadataValues(keys);
}
private addMetaTag(property: string, content: string): void {
diff --git a/src/app/core/metadata/metadatafield.model.ts b/src/app/core/metadata/metadatafield.model.ts
index 77cecb927e..f9b5155649 100644
--- a/src/app/core/metadata/metadatafield.model.ts
+++ b/src/app/core/metadata/metadatafield.model.ts
@@ -3,6 +3,9 @@ import { autoserialize } from 'cerialize';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
export class MetadataField implements ListableObject {
+ @autoserialize
+ id: number;
+
@autoserialize
self: string;
diff --git a/src/app/core/metadata/normalized-metadata-schema.model.ts b/src/app/core/metadata/normalized-metadata-schema.model.ts
new file mode 100644
index 0000000000..844efd232f
--- /dev/null
+++ b/src/app/core/metadata/normalized-metadata-schema.model.ts
@@ -0,0 +1,36 @@
+import { autoserialize } from 'cerialize';
+import { NormalizedObject } from '../cache/models/normalized-object.model';
+import { mapsTo } from '../cache/builders/build-decorators';
+import { CacheableObject } from '../cache/object-cache.reducer';
+import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
+import { MetadataSchema } from './metadataschema.model';
+
+/**
+ * Normalized class for a DSpace MetadataSchema
+ */
+@mapsTo(MetadataSchema)
+export class NormalizedMetadataSchema extends NormalizedObject implements CacheableObject, ListableObject {
+ /**
+ * The unique identifier for this schema
+ */
+ @autoserialize
+ id: number;
+
+ /**
+ * The REST link to itself
+ */
+ @autoserialize
+ self: string;
+
+ /**
+ * A unique prefix that defines this schema
+ */
+ @autoserialize
+ prefix: string;
+
+ /**
+ * The namespace for this schema
+ */
+ @autoserialize
+ namespace: string;
+}
diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts
index de394c96a0..435be22ead 100644
--- a/src/app/core/registry/registry.service.spec.ts
+++ b/src/app/core/registry/registry.service.spec.ts
@@ -14,13 +14,30 @@ import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import {
RegistryBitstreamformatsSuccessResponse,
RegistryMetadatafieldsSuccessResponse,
- RegistryMetadataschemasSuccessResponse
+ RegistryMetadataschemasSuccessResponse, RestResponse
} from '../cache/response.models';
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 } from '@ngrx/store';
+import { AppState } from '../../app.reducer';
+import { MockStore } from '../../shared/testing/mock-store';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub';
+import { TranslateModule } from '@ngx-translate/core';
+import {
+ MetadataRegistryCancelFieldAction,
+ MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction,
+ MetadataRegistryDeselectAllSchemaAction, MetadataRegistryDeselectFieldAction,
+ MetadataRegistryDeselectSchemaAction,
+ MetadataRegistryEditFieldAction,
+ MetadataRegistryEditSchemaAction, MetadataRegistrySelectFieldAction,
+ MetadataRegistrySelectSchemaAction
+} from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions';
+import { MetadataSchema } from '../metadata/metadataschema.model';
+import { MetadataField } from '../metadata/metadatafield.model';
@Component({ template: '' })
class DummyComponent {
@@ -49,31 +66,35 @@ describe('RegistryService', () => {
];
const mockFieldsList = [
{
+ id: 1,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8',
element: 'contributor',
qualifier: 'advisor',
- scopenote: null,
+ scopeNote: null,
schema: mockSchemasList[0]
},
{
+ id: 2,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9',
element: 'contributor',
qualifier: 'author',
- scopenote: null,
+ scopeNote: null,
schema: mockSchemasList[0]
},
{
+ id: 3,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10',
element: 'contributor',
qualifier: 'editor',
- scopenote: 'test scope note',
+ scopeNote: 'test scope note',
schema: mockSchemasList[1]
},
{
+ id: 4,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11',
element: 'contributor',
qualifier: 'illustrator',
- scopenote: null,
+ scopeNote: null,
schema: mockSchemasList[1]
}
];
@@ -118,6 +139,7 @@ describe('RegistryService', () => {
const endpoint = 'path';
const endpointWithParams = `${endpoint}?size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`;
+ const fieldEndpointWithParams = `${endpoint}?schema=${mockSchemasList[0].prefix}&size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`;
const halServiceStub = {
getEndpoint: (link: string) => observableOf(endpoint)
@@ -136,9 +158,11 @@ describe('RegistryService', () => {
}
};
+ const mockStore = new MockStore(Object.create(null));
+
beforeEach(() => {
TestBed.configureTestingModule({
- imports: [CommonModule],
+ imports: [CommonModule, TranslateModule.forRoot()],
declarations: [
DummyComponent
],
@@ -146,6 +170,8 @@ describe('RegistryService', () => {
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: rdbStub },
{ provide: HALEndpointService, useValue: halServiceStub },
+ { provide: Store, useValue: mockStore },
+ { provide: NotificationsService, useValue: new NotificationsServiceStub() },
RegistryService
]
});
@@ -237,7 +263,7 @@ describe('RegistryService', () => {
});
it('should call getByHref on the request service with the correct request url', () => {
- expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams);
+ expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(fieldEndpointWithParams);
});
});
@@ -269,4 +295,186 @@ describe('RegistryService', () => {
expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams);
});
});
+
+ describe('when dispatching to the store', () => {
+ beforeEach(() => {
+ spyOn(mockStore, 'dispatch');
+ });
+
+ describe('when calling editMetadataSchema', () => {
+ beforeEach(() => {
+ registryService.editMetadataSchema(mockSchemasList[0]);
+ });
+
+ it('should dispatch a MetadataRegistryEditSchemaAction with the correct schema', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditSchemaAction(mockSchemasList[0]));
+ })
+ });
+
+ describe('when calling cancelEditMetadataSchema', () => {
+ beforeEach(() => {
+ registryService.cancelEditMetadataSchema();
+ });
+
+ it('should dispatch a MetadataRegistryCancelSchemaAction', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelSchemaAction());
+ })
+ });
+
+ describe('when calling selectMetadataSchema', () => {
+ beforeEach(() => {
+ registryService.selectMetadataSchema(mockSchemasList[0]);
+ });
+
+ it('should dispatch a MetadataRegistrySelectSchemaAction with the correct schema', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectSchemaAction(mockSchemasList[0]));
+ })
+ });
+
+ describe('when calling deselectMetadataSchema', () => {
+ beforeEach(() => {
+ registryService.deselectMetadataSchema(mockSchemasList[0]);
+ });
+
+ it('should dispatch a MetadataRegistryDeselectSchemaAction with the correct schema', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectSchemaAction(mockSchemasList[0]));
+ })
+ });
+
+ describe('when calling deselectAllMetadataSchema', () => {
+ beforeEach(() => {
+ registryService.deselectAllMetadataSchema();
+ });
+
+ it('should dispatch a MetadataRegistryDeselectAllSchemaAction', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllSchemaAction());
+ })
+ });
+
+ describe('when calling editMetadataField', () => {
+ beforeEach(() => {
+ registryService.editMetadataField(mockFieldsList[0]);
+ });
+
+ it('should dispatch a MetadataRegistryEditFieldAction with the correct Field', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditFieldAction(mockFieldsList[0]));
+ })
+ });
+
+ describe('when calling cancelEditMetadataField', () => {
+ beforeEach(() => {
+ registryService.cancelEditMetadataField();
+ });
+
+ it('should dispatch a MetadataRegistryCancelFieldAction', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelFieldAction());
+ })
+ });
+
+ describe('when calling selectMetadataField', () => {
+ beforeEach(() => {
+ registryService.selectMetadataField(mockFieldsList[0]);
+ });
+
+ it('should dispatch a MetadataRegistrySelectFieldAction with the correct Field', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectFieldAction(mockFieldsList[0]));
+ })
+ });
+
+ describe('when calling deselectMetadataField', () => {
+ beforeEach(() => {
+ registryService.deselectMetadataField(mockFieldsList[0]);
+ });
+
+ it('should dispatch a MetadataRegistryDeselectFieldAction with the correct Field', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectFieldAction(mockFieldsList[0]));
+ })
+ });
+
+ describe('when calling deselectAllMetadataField', () => {
+ beforeEach(() => {
+ registryService.deselectAllMetadataField();
+ });
+
+ it('should dispatch a MetadataRegistryDeselectAllFieldAction', () => {
+ expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllFieldAction());
+ })
+ });
+ });
+
+ describe('when createOrUpdateMetadataSchema is called', () => {
+ let result: Observable;
+
+ beforeEach(() => {
+ result = registryService.createOrUpdateMetadataSchema(mockSchemasList[0]);
+ });
+
+ it('should return the created/updated metadata schema', () => {
+ result.subscribe((schema: MetadataSchema) => {
+ expect(schema).toEqual(mockSchemasList[0]);
+ });
+ });
+ });
+
+ describe('when createOrUpdateMetadataField is called', () => {
+ let result: Observable;
+
+ beforeEach(() => {
+ result = registryService.createOrUpdateMetadataField(mockFieldsList[0]);
+ });
+
+ it('should return the created/updated metadata field', () => {
+ result.subscribe((field: MetadataField) => {
+ expect(field).toEqual(mockFieldsList[0]);
+ });
+ });
+ });
+
+ describe('when deleteMetadataSchema is called', () => {
+ let result: Observable;
+
+ beforeEach(() => {
+ result = registryService.deleteMetadataSchema(mockSchemasList[0].id);
+ });
+
+ it('should return a successful response', () => {
+ result.subscribe((response: RestResponse) => {
+ expect(response.isSuccessful).toBe(true);
+ });
+ })
+ });
+
+ describe('when deleteMetadataField is called', () => {
+ let result: Observable;
+
+ beforeEach(() => {
+ result = registryService.deleteMetadataField(mockFieldsList[0].id);
+ });
+
+ it('should return a successful response', () => {
+ result.subscribe((response: RestResponse) => {
+ expect(response.isSuccessful).toBe(true);
+ });
+ })
+ });
+
+ describe('when clearMetadataSchemaRequests is called', () => {
+ beforeEach(() => {
+ registryService.clearMetadataSchemaRequests().subscribe();
+ });
+
+ it('should remove the requests related to metadata schemas from cache', () => {
+ expect((registryService as any).requestService.removeByHrefSubstring).toHaveBeenCalled();
+ });
+ });
+
+ describe('when clearMetadataFieldRequests is called', () => {
+ beforeEach(() => {
+ registryService.clearMetadataFieldRequests().subscribe();
+ });
+
+ it('should remove the requests related to metadata fields from cache', () => {
+ expect((registryService as any).requestService.removeByHrefSubstring).toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts
index ef92d42ce9..969024e330 100644
--- a/src/app/core/registry/registry.service.ts
+++ b/src/app/core/registry/registry.service.ts
@@ -6,8 +6,14 @@ import { PageInfo } from '../shared/page-info.model';
import { MetadataSchema } from '../metadata/metadataschema.model';
import { MetadataField } from '../metadata/metadatafield.model';
import { BitstreamFormat } from './mock-bitstream-format.model';
-import { filter, flatMap, map, tap } from 'rxjs/operators';
-import { GetRequest, RestRequest } from '../data/request.models';
+import {
+ CreateMetadataFieldRequest,
+ CreateMetadataSchemaRequest,
+ DeleteRequest,
+ GetRequest,
+ RestRequest, UpdateMetadataFieldRequest,
+ UpdateMetadataSchemaRequest
+} from '../data/request.models';
import { GenericConstructor } from '../shared/generic-constructor';
import { ResponseParsingService } from '../data/parsing.service';
import { RegistryMetadataschemasResponseParsingService } from '../data/registry-metadataschemas-response-parsing.service';
@@ -15,20 +21,49 @@ 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,
RegistryMetadatafieldsSuccessResponse,
- RegistryMetadataschemasSuccessResponse
+ 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, isNotEmpty } from '../../shared/empty.util';
+import { 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 { RequestEntry } from '../data/request.reducer';
-import { getResponseFromEntry } from '../shared/operators';
+import { configureRequest, getResponseFromEntry, getSucceededRemoteData } 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';
+import {
+ MetadataRegistryCancelFieldAction,
+ MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, MetadataRegistryDeselectAllSchemaAction,
+ MetadataRegistryDeselectFieldAction,
+ MetadataRegistryDeselectSchemaAction,
+ MetadataRegistryEditFieldAction,
+ MetadataRegistryEditSchemaAction,
+ MetadataRegistrySelectFieldAction,
+ MetadataRegistrySelectSchemaAction
+} from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions';
+import { distinctUntilChanged, flatMap, map, switchMap, take, tap } from 'rxjs/operators';
+import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
+import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory';
+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';
+import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
+import { HttpHeaders } from '@angular/common/http';
+import { TranslateService } from '@ngx-translate/core';
+
+const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry;
+const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema);
+const selectedMetadataSchemasSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.selectedSchemas);
+const editMetadataFieldSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editField);
+const selectedMetadataFieldsSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.selectedFields);
@Injectable()
export class RegistryService {
@@ -39,7 +74,10 @@ export class RegistryService {
constructor(protected requestService: RequestService,
private rdb: RemoteDataBuildService,
- private halService: HALEndpointService) {
+ private halService: HALEndpointService,
+ private store: Store,
+ private notificationsService: NotificationsService,
+ private translateService: TranslateService) {
}
@@ -99,7 +137,7 @@ export class RegistryService {
}
public getMetadataFieldsBySchema(schema: MetadataSchema, pagination: PaginationComponentOptions): Observable>> {
- const requestObs = this.getMetadataFieldsRequestObs(pagination);
+ const requestObs = this.getMetadataFieldsBySchemaRequestObs(pagination, schema);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
@@ -111,8 +149,7 @@ export class RegistryService {
);
const metadatafieldsObs: Observable = rmrObs.pipe(
- map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields),
- map((metadataFields: MetadataField[]) => metadataFields.filter((field) => field.schema.id === schema.id))
+ map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields)
);
const pageInfoObs: Observable = requestEntryObs.pipe(
@@ -160,7 +197,7 @@ export class RegistryService {
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[] = [];
@@ -180,10 +217,12 @@ export class RegistryService {
);
}
- private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable {
- return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
+ private getMetadataFieldsBySchemaRequestObs(pagination: PaginationComponentOptions, schema: MetadataSchema): Observable {
+ return this.halService.getEndpoint(this.metadataFieldsPath + '/search/bySchema').pipe(
+ // return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
map((url: string) => {
const args: string[] = [];
+ args.push(`schema=${schema.prefix}`);
args.push(`size=${pagination.pageSize}`);
args.push(`page=${pagination.currentPage - 1}`);
if (isNotEmpty(args)) {
@@ -220,4 +259,240 @@ export class RegistryService {
);
}
+ public editMetadataSchema(schema: MetadataSchema) {
+ this.store.dispatch(new MetadataRegistryEditSchemaAction(schema));
+ }
+
+ public cancelEditMetadataSchema() {
+ this.store.dispatch(new MetadataRegistryCancelSchemaAction());
+ }
+
+ public getActiveMetadataSchema(): Observable {
+ return this.store.pipe(select(editMetadataSchemaSelector));
+ }
+
+ public selectMetadataSchema(schema: MetadataSchema) {
+ this.store.dispatch(new MetadataRegistrySelectSchemaAction(schema))
+ }
+
+ public deselectMetadataSchema(schema: MetadataSchema) {
+ this.store.dispatch(new MetadataRegistryDeselectSchemaAction(schema))
+ }
+
+ public deselectAllMetadataSchema() {
+ this.store.dispatch(new MetadataRegistryDeselectAllSchemaAction())
+ }
+
+ public getSelectedMetadataSchemas(): Observable {
+ return this.store.pipe(select(selectedMetadataSchemasSelector));
+ }
+
+ public editMetadataField(field: MetadataField) {
+ this.store.dispatch(new MetadataRegistryEditFieldAction(field));
+ }
+
+ public cancelEditMetadataField() {
+ this.store.dispatch(new MetadataRegistryCancelFieldAction());
+ }
+
+ public getActiveMetadataField(): Observable {
+ return this.store.pipe(select(editMetadataFieldSelector));
+ }
+
+ public selectMetadataField(field: MetadataField) {
+ this.store.dispatch(new MetadataRegistrySelectFieldAction(field))
+ }
+
+ public deselectMetadataField(field: MetadataField) {
+ this.store.dispatch(new MetadataRegistryDeselectFieldAction(field))
+ }
+
+ public deselectAllMetadataField() {
+ this.store.dispatch(new MetadataRegistryDeselectAllFieldAction())
+ }
+
+ public getSelectedMetadataFields(): Observable {
+ return this.store.pipe(select(selectedMetadataFieldsSelector));
+ }
+
+ /**
+ * Create or Update a MetadataSchema
+ * If the MetadataSchema contains an id, it is assumed the schema already exists and is updated instead
+ * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint):
+ * - On creation, a CreateMetadataSchemaRequest is used
+ * - On update, a UpdateMetadataSchemaRequest is used
+ * @param schema The MetadataSchema to create or update
+ */
+ public createOrUpdateMetadataSchema(schema: MetadataSchema): Observable {
+ const isUpdate = hasValue(schema.id);
+ const requestId = this.requestService.generateRequestId();
+ const endpoint$ = this.halService.getEndpoint(this.metadataSchemasPath).pipe(
+ isNotEmptyOperator(),
+ map((endpoint: string) => (isUpdate ? `${endpoint}/${schema.id}` : endpoint)),
+ distinctUntilChanged()
+ );
+
+ const serializedSchema = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(ResourceType.MetadataSchema)).serialize(schema as NormalizedMetadataSchema);
+
+ const request$ = endpoint$.pipe(
+ take(1),
+ map((endpoint: string) => {
+ if (isUpdate) {
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Content-Type', 'application/json');
+ options.headers = headers;
+ return new UpdateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema), options);
+ } else {
+ return new CreateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema));
+ }
+ })
+ );
+
+ // Execute the post/put request
+ request$.pipe(
+ configureRequest(this.requestService)
+ ).subscribe();
+
+ // Return created/updated schema
+ return this.requestService.getByUUID(requestId).pipe(
+ getResponseFromEntry(),
+ map((response: RestResponse) => {
+ if (!response.isSuccessful) {
+ if (hasValue((response as any).errorMessage)) {
+ this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1));
+ }
+ } else {
+ this.showNotifications(true, isUpdate, false, { prefix: schema.prefix });
+ return response;
+ }
+ }),
+ isNotEmptyOperator(),
+ map((response: MetadataschemaSuccessResponse) => {
+ if (isNotEmpty(response.metadataschema)) {
+ return response.metadataschema;
+ }
+ })
+ );
+ }
+
+ public deleteMetadataSchema(id: number): Observable {
+ return this.delete(this.metadataSchemasPath, id);
+ }
+
+ public clearMetadataSchemaRequests(): Observable {
+ return this.halService.getEndpoint(this.metadataSchemasPath).pipe(
+ tap((href: string) => this.requestService.removeByHrefSubstring(href))
+ )
+ }
+
+ /**
+ * Create or Update a MetadataField
+ * If the MetadataField contains an id, it is assumed the field already exists and is updated instead
+ * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint):
+ * - On creation, a CreateMetadataFieldRequest is used
+ * - On update, a UpdateMetadataFieldRequest is used
+ * @param field The MetadataField to create or update
+ */
+ public createOrUpdateMetadataField(field: MetadataField): Observable {
+ const isUpdate = hasValue(field.id);
+ const requestId = this.requestService.generateRequestId();
+ const endpoint$ = this.halService.getEndpoint(this.metadataFieldsPath).pipe(
+ isNotEmptyOperator(),
+ map((endpoint: string) => (isUpdate ? `${endpoint}/${field.id}` : `${endpoint}?schemaId=${field.schema.id}`)),
+ distinctUntilChanged()
+ );
+
+ const request$ = endpoint$.pipe(
+ take(1),
+ map((endpoint: string) => {
+ if (isUpdate) {
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Content-Type', 'application/json');
+ options.headers = headers;
+ return new UpdateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field), options);
+ } else {
+ return new CreateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field));
+ }
+ })
+ );
+
+ // Execute the post/put request
+ request$.pipe(
+ configureRequest(this.requestService)
+ ).subscribe();
+
+ // Return created/updated field
+ return this.requestService.getByUUID(requestId).pipe(
+ getResponseFromEntry(),
+ map((response: RestResponse) => {
+ if (!response.isSuccessful) {
+ if (hasValue((response as any).errorMessage)) {
+ this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1));
+ }
+ } else {
+ const fieldString = `${field.schema.prefix}.${field.element}${field.qualifier ? `.${field.qualifier}` : ''}`;
+ this.showNotifications(true, isUpdate, true, { field: fieldString });
+ return response;
+ }
+ }),
+ isNotEmptyOperator(),
+ map((response: MetadatafieldSuccessResponse) => {
+ if (isNotEmpty(response.metadatafield)) {
+ return response.metadatafield;
+ }
+ })
+ );
+ }
+
+ public deleteMetadataField(id: number): Observable {
+ return this.delete(this.metadataFieldsPath, id);
+ }
+
+ public clearMetadataFieldRequests(): Observable {
+ return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
+ tap((href: string) => this.requestService.removeByHrefSubstring(href))
+ )
+ }
+
+ private delete(path: string, id: number): Observable {
+ const requestId = this.requestService.generateRequestId();
+ const endpoint$ = this.halService.getEndpoint(path).pipe(
+ isNotEmptyOperator(),
+ map((endpoint: string) => `${endpoint}/${id}`),
+ distinctUntilChanged()
+ );
+
+ const request$ = endpoint$.pipe(
+ take(1),
+ map((endpoint: string) => new DeleteRequest(requestId, endpoint))
+ );
+
+ // Execute the delete request
+ request$.pipe(
+ configureRequest(this.requestService)
+ ).subscribe();
+
+ return this.requestService.getByUUID(requestId).pipe(
+ getResponseFromEntry()
+ );
+ }
+
+ private showNotifications(success: boolean, edited: boolean, isField: boolean, options: any) {
+ const prefix = 'admin.registries.schema.notification';
+ const suffix = success ? 'success' : 'failure';
+ const editedString = edited ? 'edited' : 'created';
+ const messages = observableCombineLatest(
+ this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
+ this.translateService.get(`${prefix}${isField ? '.field' : ''}.${editedString}`, options)
+ );
+ messages.subscribe(([head, content]) => {
+ if (success) {
+ this.notificationsService.success(head, content)
+ } else {
+ this.notificationsService.error(head, content)
+ }
+ });
+ }
}
diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts
index 95932ce0d6..0471d1fbbb 100644
--- a/src/app/core/shared/collection.model.ts
+++ b/src/app/core/shared/collection.model.ts
@@ -19,7 +19,7 @@ export class Collection extends DSpaceObject {
* Corresponds to the metadata field dc.description
*/
get introductoryText(): string {
- return this.findMetadata('dc.description');
+ return this.firstMetadataValue('dc.description');
}
/**
@@ -27,7 +27,7 @@ export class Collection extends DSpaceObject {
* Corresponds to the metadata field dc.description.abstract
*/
get shortDescription(): string {
- return this.findMetadata('dc.description.abstract');
+ return this.firstMetadataValue('dc.description.abstract');
}
/**
@@ -35,7 +35,7 @@ export class Collection extends DSpaceObject {
* Corresponds to the metadata field dc.rights
*/
get copyrightText(): string {
- return this.findMetadata('dc.rights');
+ return this.firstMetadataValue('dc.rights');
}
/**
@@ -43,7 +43,7 @@ export class Collection extends DSpaceObject {
* Corresponds to the metadata field dc.rights.license
*/
get dcLicense(): string {
- return this.findMetadata('dc.rights.license');
+ return this.firstMetadataValue('dc.rights.license');
}
/**
@@ -51,7 +51,7 @@ export class Collection extends DSpaceObject {
* Corresponds to the metadata field dc.description.tableofcontents
*/
get sidebarText(): string {
- return this.findMetadata('dc.description.tableofcontents');
+ return this.firstMetadataValue('dc.description.tableofcontents');
}
/**
diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts
index c5f6e0ab87..c4e703fd7f 100644
--- a/src/app/core/shared/community.model.ts
+++ b/src/app/core/shared/community.model.ts
@@ -17,7 +17,7 @@ export class Community extends DSpaceObject {
* Corresponds to the metadata field dc.description
*/
get introductoryText(): string {
- return this.findMetadata('dc.description');
+ return this.firstMetadataValue('dc.description');
}
/**
@@ -25,7 +25,7 @@ export class Community extends DSpaceObject {
* Corresponds to the metadata field dc.description.abstract
*/
get shortDescription(): string {
- return this.findMetadata('dc.description.abstract');
+ return this.firstMetadataValue('dc.description.abstract');
}
/**
@@ -33,7 +33,7 @@ export class Community extends DSpaceObject {
* Corresponds to the metadata field dc.rights
*/
get copyrightText(): string {
- return this.findMetadata('dc.rights');
+ return this.firstMetadataValue('dc.rights');
}
/**
@@ -41,7 +41,7 @@ export class Community extends DSpaceObject {
* Corresponds to the metadata field dc.description.tableofcontents
*/
get sidebarText(): string {
- return this.findMetadata('dc.description.tableofcontents');
+ return this.firstMetadataValue('dc.description.tableofcontents');
}
/**
diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts
index f61f013064..100d4da557 100644
--- a/src/app/core/shared/dspace-object.model.ts
+++ b/src/app/core/shared/dspace-object.model.ts
@@ -1,4 +1,5 @@
-import { Metadatum } from './metadatum.model'
+import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces';
+import { Metadata } from './metadata.model';
import { isEmpty, isNotEmpty, isUndefined } from '../../shared/empty.util';
import { CacheableObject } from '../cache/object-cache.reducer';
import { RemoteData } from '../data/remote-data';
@@ -37,7 +38,7 @@ export class DSpaceObject implements CacheableObject, ListableObject {
* The name for this DSpaceObject
*/
get name(): string {
- return (isUndefined(this._name)) ? this.findMetadata('dc.title') : this._name;
+ return (isUndefined(this._name)) ? this.firstMetadataValue('dc.title') : this._name;
}
/**
@@ -48,10 +49,10 @@ export class DSpaceObject implements CacheableObject, ListableObject {
}
/**
- * An array containing all metadata of this DSpaceObject
+ * All metadata of this DSpaceObject
*/
@autoserialize
- metadata: Metadatum[] = [];
+ metadata: MetadataMap;
/**
* An array of DSpaceObjects that are direct parents of this DSpaceObject
@@ -64,41 +65,58 @@ export class DSpaceObject implements CacheableObject, ListableObject {
owner: Observable>;
/**
- * Find a metadata field by key and language
+ * Gets all matching metadata in this DSpaceObject.
*
- * This method returns the value of the first element
- * in the metadata array that matches the provided
- * key and language
- *
- * @param key
- * @param language
- * @return string
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]].
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {MetadataValue[]} the matching values or an empty array.
*/
- findMetadata(key: string, language?: string): string {
- const metadatum = (this.metadata) ? this.metadata.find((m: Metadatum) => {
- return m.key === key && (isEmpty(language) || m.language === language)
- }) : null;
- if (isNotEmpty(metadatum)) {
- return metadatum.value;
- } else {
- return undefined;
- }
+ allMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue[] {
+ return Metadata.all(this.metadata, keyOrKeys, valueFilter);
}
/**
- * Find metadata by an array of keys
+ * Like [[allMetadata]], but only returns string values.
*
- * This method returns the values of the element
- * in the metadata array that match the provided
- * key(s)
- *
- * @param key(s)
- * @return Array
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]].
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {string[]} the matching string values or an empty array.
*/
- filterMetadata(keys: string[]): Metadatum[] {
- return (this.metadata || []).filter((metadatum: Metadatum) => {
- return keys.some((key) => key === metadatum.key);
- });
+ allMetadataValues(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string[] {
+ return Metadata.allValues(this.metadata, keyOrKeys, valueFilter);
+ }
+
+ /**
+ * Gets the first matching MetadataValue object in this DSpaceObject, or `undefined`.
+ *
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]].
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {MetadataValue} the first matching value, or `undefined`.
+ */
+ firstMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue {
+ return Metadata.first(this.metadata, keyOrKeys, valueFilter);
+ }
+
+ /**
+ * Like [[firstMetadata]], but only returns a string value, or `undefined`.
+ *
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]].
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {string} the first matching string value, or `undefined`.
+ */
+ firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
+ return Metadata.firstValue(this.metadata, keyOrKeys, valueFilter);
+ }
+
+ /**
+ * Checks for a matching metadata value in this DSpaceObject.
+ *
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]].
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {boolean} whether a match is found.
+ */
+ hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean {
+ return Metadata.has(this.metadata, keyOrKeys, valueFilter);
}
}
diff --git a/src/app/core/shared/metadata.interfaces.ts b/src/app/core/shared/metadata.interfaces.ts
new file mode 100644
index 0000000000..3590117ce8
--- /dev/null
+++ b/src/app/core/shared/metadata.interfaces.ts
@@ -0,0 +1,30 @@
+/** A map of metadata keys to an ordered list of MetadataValue objects. */
+export interface MetadataMap {
+ [ key: string ]: MetadataValue[];
+}
+
+/** A single metadata value and its properties. */
+export interface MetadataValue {
+
+ /** The language. */
+ language: string;
+
+ /** The string value. */
+ value: string;
+}
+
+/** Constraints for matching metadata values. */
+export interface MetadataValueFilter {
+
+ /** The language constraint. */
+ language?: string;
+
+ /** The value constraint. */
+ value?: string;
+
+ /** Whether the value constraint should match without regard to case. */
+ ignoreCase?: boolean;
+
+ /** Whether the value constraint should match as a substring. */
+ substring?: boolean;
+}
diff --git a/src/app/core/shared/metadata.model.spec.ts b/src/app/core/shared/metadata.model.spec.ts
new file mode 100644
index 0000000000..dfeff8d600
--- /dev/null
+++ b/src/app/core/shared/metadata.model.spec.ts
@@ -0,0 +1,175 @@
+import { isUndefined } from '../../shared/empty.util';
+import { MetadataValue, MetadataValueFilter } from './metadata.interfaces';
+import { Metadata } from './metadata.model';
+
+const mdValue = (value: string, language?: string): MetadataValue => {
+ return { value: value, language: isUndefined(language) ? null : language };
+}
+
+const dcDescription = mdValue('Some description');
+const dcAbstract = mdValue('Some abstract');
+const dcTitle0 = mdValue('Title 0');
+const dcTitle1 = mdValue('Title 1');
+const dcTitle2 = mdValue('Title 2', 'en_US');
+const bar = mdValue('Bar');
+
+const singleMap = { 'dc.title': [ dcTitle0 ] };
+
+const multiMap = {
+ 'dc.description': [ dcDescription ],
+ 'dc.description.abstract': [ dcAbstract ],
+ 'dc.title': [ dcTitle1, dcTitle2 ],
+ 'foo': [ bar ]
+};
+
+const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => {
+ const keys = keyOrKeys instanceof Array ? keyOrKeys : [ keyOrKeys ];
+ describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys)))
+ + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => {
+ const result = fn(mapOrMaps, keys, filter);
+ let shouldReturn;
+ if (resultKind === 'boolean') {
+ shouldReturn = expected;
+ } else if (isUndefined(expected)) {
+ shouldReturn = 'undefined';
+ } else if (expected instanceof Array) {
+ shouldReturn = 'an array with ' + expected.length + ' ' + (expected.length > 1 ? 'ordered ' : '')
+ + resultKind + (expected.length !== 1 ? 's' : '');
+ } else {
+ shouldReturn = 'a ' + resultKind;
+ }
+ it('should return ' + shouldReturn, () => {
+ expect(result).toEqual(expected);
+ });
+ })
+};
+
+describe('Metadata', () => {
+
+ describe('all method', () => {
+
+ const testAll = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) =>
+ testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, expected, filter);
+
+ describe('with emptyMap', () => {
+ testAll({}, 'foo', []);
+ testAll({}, '*', []);
+ });
+ describe('with singleMap', () => {
+ testAll(singleMap, 'foo', []);
+ testAll(singleMap, '*', [ dcTitle0 ]);
+ testAll(singleMap, '*', [], { value: 'baz' });
+ testAll(singleMap, 'dc.title', [ dcTitle0 ]);
+ testAll(singleMap, 'dc.*', [ dcTitle0 ]);
+ });
+ describe('with multiMap', () => {
+ testAll(multiMap, 'foo', [ bar ]);
+ testAll(multiMap, '*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2, bar ]);
+ testAll(multiMap, 'dc.title', [ dcTitle1, dcTitle2 ]);
+ testAll(multiMap, 'dc.*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2 ]);
+ testAll(multiMap, [ 'dc.title', 'dc.*' ], [ dcTitle1, dcTitle2, dcDescription, dcAbstract ]);
+ });
+ describe('with [ singleMap, multiMap ]', () => {
+ testAll([ singleMap, multiMap ], 'foo', [ bar ]);
+ testAll([ singleMap, multiMap ], '*', [ dcTitle0 ]);
+ testAll([ singleMap, multiMap ], 'dc.title', [ dcTitle0 ]);
+ testAll([ singleMap, multiMap ], 'dc.*', [ dcTitle0 ]);
+ });
+ describe('with [ multiMap, singleMap ]', () => {
+ testAll([ multiMap, singleMap ], 'foo', [ bar ]);
+ testAll([ multiMap, singleMap ], '*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2, bar ]);
+ testAll([ multiMap, singleMap ], 'dc.title', [ dcTitle1, dcTitle2 ]);
+ testAll([ multiMap, singleMap ], 'dc.*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2 ]);
+ testAll([ multiMap, singleMap ], [ 'dc.title', 'dc.*' ], [ dcTitle1, dcTitle2, dcDescription, dcAbstract ]);
+ });
+ });
+
+ describe('allValues method', () => {
+
+ const testAllValues = (mapOrMaps, keyOrKeys, expected) =>
+ testMethod(Metadata.allValues, 'string', mapOrMaps, keyOrKeys, expected);
+
+ describe('with emptyMap', () => {
+ testAllValues({}, '*', []);
+ });
+ describe('with singleMap', () => {
+ testAllValues([ singleMap, multiMap ], '*', [ dcTitle0.value ]);
+ });
+ describe('with [ multiMap, singleMap ]', () => {
+ testAllValues([ multiMap, singleMap ], '*', [ dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value ]);
+ });
+ });
+
+ describe('first method', () => {
+
+ const testFirst = (mapOrMaps, keyOrKeys, expected) =>
+ testMethod(Metadata.first, 'value', mapOrMaps, keyOrKeys, expected);
+
+ describe('with emptyMap', () => {
+ testFirst({}, '*', undefined);
+ });
+ describe('with singleMap', () => {
+ testFirst(singleMap, '*', dcTitle0);
+ });
+ describe('with [ multiMap, singleMap ]', () => {
+ testFirst([ multiMap, singleMap ], '*', dcDescription);
+ });
+ });
+
+ describe('firstValue method', () => {
+
+ const testFirstValue = (mapOrMaps, keyOrKeys, expected) =>
+ testMethod(Metadata.firstValue, 'value', mapOrMaps, keyOrKeys, expected);
+
+ describe('with emptyMap', () => {
+ testFirstValue({}, '*', undefined);
+ });
+ describe('with singleMap', () => {
+ testFirstValue(singleMap, '*', dcTitle0.value);
+ });
+ describe('with [ multiMap, singleMap ]', () => {
+ testFirstValue([ multiMap, singleMap ], '*', dcDescription.value);
+ });
+ });
+
+ describe('has method', () => {
+
+ const testHas = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) =>
+ testMethod(Metadata.has, 'boolean', mapOrMaps, keyOrKeys, expected, filter);
+
+ describe('with emptyMap', () => {
+ testHas({}, '*', false);
+ });
+ describe('with singleMap', () => {
+ testHas(singleMap, '*', true);
+ testHas(singleMap, '*', false, { value: 'baz' });
+ });
+ describe('with [ multiMap, singleMap ]', () => {
+ testHas([ multiMap, singleMap ], '*', true);
+ });
+ });
+
+ describe('valueMatches method', () => {
+
+ const testValueMatches = (value: MetadataValue, expected: boolean, filter?: MetadataValueFilter) => {
+ describe('with value ' + JSON.stringify(value) + ' and filter '
+ + (isUndefined(filter) ? 'undefined' : JSON.stringify(filter)), () => {
+ const result = Metadata.valueMatches(value, filter);
+ it('should return ' + expected, () => {
+ expect(result).toEqual(expected);
+ });
+ });
+ };
+
+ testValueMatches(mdValue('a'), true);
+ testValueMatches(mdValue('a'), true, { value: 'a' });
+ testValueMatches(mdValue('a'), false, { value: 'A' });
+ testValueMatches(mdValue('a'), true, { value: 'A', ignoreCase: true });
+ testValueMatches(mdValue('ab'), false, { value: 'b' });
+ testValueMatches(mdValue('ab'), true, { value: 'b', substring: true });
+ testValueMatches(mdValue('a'), true, { language: null });
+ testValueMatches(mdValue('a'), false, { language: 'en_US' });
+ testValueMatches(mdValue('a', 'en_US'), true, { language: 'en_US' });
+ });
+
+});
diff --git a/src/app/core/shared/metadata.model.ts b/src/app/core/shared/metadata.model.ts
new file mode 100644
index 0000000000..2b29659252
--- /dev/null
+++ b/src/app/core/shared/metadata.model.ts
@@ -0,0 +1,163 @@
+import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util';
+import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces';
+
+/**
+ * Utility class for working with DSpace object metadata.
+ *
+ * When specifying metadata keys, wildcards are supported, so `'*'` will match all keys, `'dc.date.*'` will
+ * match all qualified dc dates, and so on. Exact keys will be evaluated (and matches returned) in the order
+ * they are given.
+ *
+ * When multiple keys in a map match a given wildcard, they are evaluated in the order they are stored in
+ * the map (alphanumeric if obtained from the REST api). If duplicate or overlapping keys are specified, the
+ * first one takes precedence. For example, specifying `['dc.date', 'dc.*', '*']` will cause any `dc.date`
+ * values to be evaluated (and returned, if matched) first, followed by any other `dc` metadata values,
+ * followed by any other (non-dc) metadata values.
+ */
+export class Metadata {
+
+ /**
+ * Gets all matching metadata in the map(s).
+ *
+ * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). When multiple maps are given, they will be
+ * checked in order, and only values from the first with at least one match will be returned.
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {MetadataValue[]} the matching values or an empty array.
+ */
+ public static all(mapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[],
+ filter?: MetadataValueFilter): MetadataValue[] {
+ const mdMaps: MetadataMap[] = mapOrMaps instanceof Array ? mapOrMaps : [ mapOrMaps ];
+ const matches: MetadataValue[] = [];
+ for (const mdMap of mdMaps) {
+ for (const mdKey of Metadata.resolveKeys(mdMap, keyOrKeys)) {
+ const candidates = mdMap[mdKey];
+ if (candidates) {
+ for (const candidate of candidates) {
+ if (Metadata.valueMatches(candidate, filter)) {
+ matches.push(candidate);
+ }
+ }
+ }
+ }
+ if (!isEmpty(matches)) {
+ return matches;
+ }
+ }
+ return matches;
+ }
+
+ /**
+ * Like [[Metadata.all]], but only returns string values.
+ *
+ * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). When multiple maps are given, they will be
+ * checked in order, and only values from the first with at least one match will be returned.
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {string[]} the matching string values or an empty array.
+ */
+ public static allValues(mapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[],
+ filter?: MetadataValueFilter): string[] {
+ return Metadata.all(mapOrMaps, keyOrKeys, filter).map((mdValue) => mdValue.value);
+ }
+
+ /**
+ * Gets the first matching MetadataValue object in the map(s), or `undefined`.
+ *
+ * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s).
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {MetadataValue} the first matching value, or `undefined`.
+ */
+ public static first(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[],
+ filter?: MetadataValueFilter): MetadataValue {
+ const mdMaps: MetadataMap[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [ mdMapOrMaps ];
+ for (const mdMap of mdMaps) {
+ for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) {
+ const values: MetadataValue[] = mdMap[key];
+ if (values) {
+ return values.find((value: MetadataValue) => Metadata.valueMatches(value, filter));
+ }
+ }
+ }
+ }
+
+ /**
+ * Like [[Metadata.first]], but only returns a string value, or `undefined`.
+ *
+ * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s).
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {string} the first matching string value, or `undefined`.
+ */
+ public static firstValue(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[],
+ filter?: MetadataValueFilter): string {
+ const value = Metadata.first(mdMapOrMaps, keyOrKeys, filter);
+ return isUndefined(value) ? undefined : value.value;
+ }
+
+ /**
+ * Checks for a matching metadata value in the given map(s).
+ *
+ * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s).
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
+ * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
+ * @returns {boolean} whether a match is found.
+ */
+ public static has(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[],
+ filter?: MetadataValueFilter): boolean {
+ return isNotUndefined(Metadata.first(mdMapOrMaps, keyOrKeys, filter));
+ }
+
+ /**
+ * Checks if a value matches a filter.
+ *
+ * @param {MetadataValue} mdValue the value to check.
+ * @param {MetadataValueFilter} filter the filter to use.
+ * @returns {boolean} whether the filter matches, or true if no filter is given.
+ */
+ public static valueMatches(mdValue: MetadataValue, filter: MetadataValueFilter) {
+ if (!filter) {
+ return true;
+ } else if (filter.language && filter.language !== mdValue.language) {
+ return false;
+ } else if (filter.value) {
+ let fValue = filter.value;
+ let mValue = mdValue.value;
+ if (filter.ignoreCase) {
+ fValue = filter.value.toLowerCase();
+ mValue = mdValue.value.toLowerCase();
+ }
+ if (filter.substring) {
+ return mValue.includes(fValue);
+ } else {
+ return mValue === fValue;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Gets the list of keys in the map limited by, and in the order given by `keyOrKeys`.
+ *
+ * @param {MetadataMap} mdMap The source map.
+ * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
+ */
+ private static resolveKeys(mdMap: MetadataMap, keyOrKeys: string | string[]): string[] {
+ const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [ keyOrKeys ];
+ const outputKeys: string[] = [];
+ for (const inputKey of inputKeys) {
+ if (inputKey.includes('*')) {
+ const inputKeyRegex = new RegExp('^' + inputKey.replace('.', '\.').replace('*', '.*') + '$');
+ for (const mapKey of Object.keys(mdMap)) {
+ if (!outputKeys.includes(mapKey) && inputKeyRegex.test(mapKey)) {
+ outputKeys.push(mapKey);
+ }
+ }
+ } else if (mdMap.hasOwnProperty(inputKey) && !outputKeys.includes(inputKey)) {
+ outputKeys.push(inputKey);
+ }
+ }
+ return outputKeys;
+ }
+}
diff --git a/src/app/core/shared/metadatum.model.ts b/src/app/core/shared/metadatum.model.ts
deleted file mode 100644
index a3c5830608..0000000000
--- a/src/app/core/shared/metadatum.model.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { autoserialize } from 'cerialize';
-
-export class Metadatum {
-
- /**
- * The metadata field of this Metadatum
- */
- @autoserialize
- key: string;
-
- /**
- * The language of this Metadatum
- */
- @autoserialize
- language: string;
-
- /**
- * The value of this Metadatum
- */
- @autoserialize
- value: string;
-
-}
diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts
index 5a325509bf..9a429df5e1 100644
--- a/src/app/core/shared/operators.ts
+++ b/src/app/core/shared/operators.ts
@@ -60,7 +60,7 @@ export const getRemoteDataPayload = () =>
export const getSucceededRemoteData = () =>
(source: Observable>): Observable> =>
- source.pipe(find((rd: RemoteData) => rd.hasSucceeded));
+ source.pipe(find((rd: RemoteData) => rd.hasSucceeded), hasValueOperator());
export const getFinishedRemoteData = () =>
(source: Observable>): Observable> =>
diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts
index 4b14de3e10..484f1ea6e2 100644
--- a/src/app/core/shared/resource-type.ts
+++ b/src/app/core/shared/resource-type.ts
@@ -9,6 +9,8 @@ export enum ResourceType {
EPerson = 'eperson',
Group = 'group',
ResourcePolicy = 'resourcePolicy',
+ MetadataSchema = 'metadataschema',
+ MetadataField = 'metadatafield',
License = 'license',
Workflowitem = 'workflowitem',
Workspaceitem = 'workspaceitem',
diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
index a6f5e0d45a..cc1e2063ff 100644
--- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
+++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
@@ -11,7 +11,6 @@ import { ResourceType } from '../../../core/shared/resource-type';
import { ComColFormComponent } from './comcol-form.component';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { hasValue } from '../../empty.util';
-import { Metadatum } from '../../../core/shared/metadatum.model';
describe('ComColFormComponent', () => {
let comp: ComColFormComponent;
@@ -29,23 +28,24 @@ describe('ComColFormComponent', () => {
return undefined;
}
};
- const titleMD = { key: 'dc.title', value: 'Community Title' } as Metadatum;
- const randomMD = { key: 'dc.random', value: 'Random metadata excluded from form' } as Metadatum;
- const abstractMD = {
- key: 'dc.description.abstract',
- value: 'Community description'
- } as Metadatum;
- const newTitleMD = { key: 'dc.title', value: 'New Community Title' } as Metadatum;
+ const dcTitle = 'dc.title';
+ const dcRandom = 'dc.random';
+ const dcAbstract = 'dc.description.abstract';
+
+ const titleMD = { [dcTitle]: [ { value: 'Community Title', language: null } ] };
+ const randomMD = { [dcRandom]: [ { value: 'Random metadata excluded from form', language: null } ] };
+ const abstractMD = { [dcAbstract]: [ { value: 'Community description', language: null } ] };
+ const newTitleMD = { [dcTitle]: [ { value: 'New Community Title', language: null } ] };
const formModel = [
new DynamicInputModel({
id: 'title',
- name: newTitleMD.key,
- value: 'New Community Title'
+ name: dcTitle,
+ value: newTitleMD[dcTitle][0].value
}),
new DynamicInputModel({
id: 'abstract',
- name: abstractMD.key,
- value: abstractMD.value
+ name: dcAbstract,
+ value: abstractMD[dcAbstract][0].value
})
];
@@ -87,10 +87,10 @@ describe('ComColFormComponent', () => {
comp.dso = Object.assign(
new Community(),
{
- metadata: [
- titleMD,
- randomMD
- ]
+ metadata: {
+ ...titleMD,
+ ...randomMD
+ }
}
);
@@ -101,11 +101,11 @@ describe('ComColFormComponent', () => {
{},
new Community(),
{
- metadata: [
- randomMD,
- newTitleMD,
- abstractMD
- ],
+ metadata: {
+ ...newTitleMD,
+ ...randomMD,
+ ...abstractMD
+ },
type: ResourceType.Community
},
)
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 17710fd1c6..0e0195aaaa 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
@@ -8,6 +8,7 @@ import { FormGroup } from '@angular/forms';
import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model';
import { TranslateService } from '@ngx-translate/core';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.interfaces';
import { isNotEmpty } from '../../empty.util';
import { ResourceType } from '../../../core/shared/resource-type';
@@ -64,7 +65,7 @@ export class ComColFormComponent implements OnInit {
ngOnInit(): void {
this.formModel.forEach(
(fieldModel: DynamicInputModel) => {
- fieldModel.value = this.dso.findMetadata(fieldModel.name);
+ fieldModel.value = this.dso.firstMetadataValue(fieldModel.name);
}
);
this.formGroup = this.formService.createFormGroup(this.formModel);
@@ -77,20 +78,24 @@ export class ComColFormComponent implements OnInit {
}
/**
- * Checks which new fields where added and sends the updated version of the DSO to the parent component
+ * Checks which new fields were added and sends the updated version of the DSO to the parent component
*/
onSubmit() {
- const metadata = this.formModel.map(
- (fieldModel: DynamicInputModel) => {
- return { key: fieldModel.name, value: fieldModel.value }
+ const formMetadata = new Object() as MetadataMap;
+ this.formModel.forEach((fieldModel: DynamicInputModel) => {
+ const value: MetadataValue = { value: fieldModel.value as string, language: null };
+ if (formMetadata.hasOwnProperty(fieldModel.name)) {
+ formMetadata[fieldModel.name].push(value);
+ } else {
+ formMetadata[fieldModel.name] = [ value ];
}
- );
- const filteredOldMetadata = this.dso.metadata.filter((filter) => !metadata.map((md) => md.key).includes(filter.key));
- const filteredNewMetadata = metadata.filter((md) => isNotEmpty(md.value));
+ });
- const newMetadata = [...filteredOldMetadata, ...filteredNewMetadata];
const updatedDSO = Object.assign({}, this.dso, {
- metadata: newMetadata,
+ metadata: {
+ ...this.dso.metadata,
+ ...formMetadata
+ },
type: ResourceType.Community
});
this.submitForm.emit(updatedDSO);
diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html
index edf14b2c67..21d4a81659 100644
--- a/src/app/shared/form/form.component.html
+++ b/src/app/shared/form/form.component.html
@@ -1,5 +1,5 @@