{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}
-
+
diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts
index 5f5388d935..025791d6dc 100644
--- a/src/app/core/data/array-move-change-analyzer.service.spec.ts
+++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts
@@ -41,21 +41,28 @@ describe('ArrayMoveChangeAnalyzer', () => {
], new MoveTest(0, 3));
testMove([
+ { op: 'move', from: '/2', path: '/3' },
{ op: 'move', from: '/0', path: '/3' },
- { op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 3), new MoveTest(1, 2));
testMove([
+ { op: 'move', from: '/3', path: '/4' },
{ op: 'move', from: '/0', path: '/1' },
- { op: 'move', from: '/3', path: '/4' }
], new MoveTest(0, 1), new MoveTest(3, 4));
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
testMove([
+ { op: 'move', from: '/2', path: '/3' },
{ op: 'move', from: '/0', path: '/3' },
- { op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
+
+ testMove([
+ { op: 'move', from: '/3', path: '/4' },
+ { op: 'move', from: '/2', path: '/4' },
+ { op: 'move', from: '/1', path: '/3' },
+ { op: 'move', from: '/0', path: '/3' },
+ ], new MoveTest(4, 1), new MoveTest(4, 2), new MoveTest(0, 3));
});
describe('when some values are undefined (index 2 and 3)', () => {
diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts
index f6e07608f6..36744e9f96 100644
--- a/src/app/core/data/array-move-change-analyzer.service.ts
+++ b/src/app/core/data/array-move-change-analyzer.service.ts
@@ -16,22 +16,31 @@ export class ArrayMoveChangeAnalyzer
{
* @param array2 The custom array to compare with the original
*/
diff(array1: T[], array2: T[]): MoveOperation[] {
- const result = [];
- const moved = [...array1];
- array1.forEach((value: T, index: number) => {
- if (hasValue(value)) {
- const otherIndex = array2.indexOf(value);
- const movedIndex = moved.indexOf(value);
- if (index !== otherIndex && movedIndex !== otherIndex) {
- moveItemInArray(moved, movedIndex, otherIndex);
- result.push(Object.assign({
- op: 'move',
- from: '/' + movedIndex,
- path: '/' + otherIndex
- }) as MoveOperation);
- }
+ return this.getMoves(array1, array2).map((move) => Object.assign({
+ op: 'move',
+ from: '/' + move[0],
+ path: '/' + move[1],
+ }) as MoveOperation);
+ }
+
+ /**
+ * Determine a set of moves required to transform array1 into array2
+ * The moves are returned as an array of pairs of numbers where the first number is the original index and the second
+ * is the new index
+ * It is assumed the operations are executed in the order they're returned (and not simultaneously)
+ * @param array1
+ * @param array2
+ */
+ private getMoves(array1: any[], array2: any[]): number[][] {
+ const moved = [...array2];
+
+ return array1.reduce((moves, item, index) => {
+ if (hasValue(item) && item !== moved[index]) {
+ const last = moved.lastIndexOf(item);
+ moveItemInArray(moved, last, index);
+ moves.unshift([index, last]);
}
- });
- return result;
+ return moves;
+ }, []);
}
}
diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts
index 3cb18bf515..9e73ca9a2b 100644
--- a/src/app/core/data/feature-authorization/feature-id.ts
+++ b/src/app/core/data/feature-authorization/feature-id.ts
@@ -29,5 +29,7 @@ export enum FeatureID {
CanViewUsageStatistics = 'canViewUsageStatistics',
CanSendFeedback = 'canSendFeedback',
CanClaimItem = 'canClaimItem',
- CanSynchronizeWithORCID = 'canSynchronizeWithORCID'
+ CanSynchronizeWithORCID = 'canSynchronizeWithORCID',
+ CanSubmit = 'canSubmit',
+ CanEditItem = 'canEditItem',
}
diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts
new file mode 100644
index 0000000000..962d53dfee
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts
@@ -0,0 +1,33 @@
+import { MetadataPatchOperation } from './metadata-patch-operation.model';
+import { Operation } from 'fast-json-patch';
+
+/**
+ * Wrapper object for a metadata patch move Operation
+ */
+export class MetadataPatchMoveOperation extends MetadataPatchOperation {
+ static operationType = 'move';
+
+ /**
+ * The original place of the metadata value to move
+ */
+ from: number;
+
+ /**
+ * The new place to move the metadata value to
+ */
+ to: number;
+
+ constructor(field: string, from: number, to: number) {
+ super(MetadataPatchMoveOperation.operationType, field);
+ this.from = from;
+ this.to = to;
+ }
+
+ /**
+ * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
+ * using the information provided.
+ */
+ toOperation(): Operation {
+ return { op: this.op as any, from: `/metadata/${this.field}/${this.from}`, path: `/metadata/${this.field}/${this.to}` };
+ }
+}
diff --git a/src/app/core/data/relationship-data.service.spec.ts b/src/app/core/data/relationship-data.service.spec.ts
index 39aa7cbd58..4432d5213a 100644
--- a/src/app/core/data/relationship-data.service.spec.ts
+++ b/src/app/core/data/relationship-data.service.spec.ts
@@ -10,13 +10,19 @@ import { DeleteRequest } from './request.models';
import { RelationshipDataService } from './relationship-data.service';
import { RequestService } from './request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
-import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import {
+ createFailedRemoteDataObject$,
+ createSuccessfulRemoteDataObject,
+ createSuccessfulRemoteDataObject$
+} from '../../shared/remote-data.utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { RequestEntry } from './request-entry.model';
import { FindListOptions } from './find-list-options.model';
import { testSearchDataImplementation } from './base/search-data.spec';
+import { MetadataValue } from '../shared/metadata.models';
+import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
describe('RelationshipDataService', () => {
let service: RelationshipDataService;
@@ -233,4 +239,152 @@ describe('RelationshipDataService', () => {
});
});
});
+
+ describe('resolveMetadataRepresentation', () => {
+ const parentItem: Item = Object.assign(new Item(), {
+ id: 'parent-item',
+ metadata: {
+ 'dc.contributor.author': [
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'Related Author with authority',
+ authority: 'virtual::related-author',
+ place: 2
+ }),
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'Author without authority',
+ place: 1
+ }),
+ ],
+ 'dc.creator': [
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'Related Creator with authority',
+ authority: 'virtual::related-creator',
+ place: 3,
+ }),
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'Related Creator with authority - unauthorized',
+ authority: 'virtual::related-creator-unauthorized',
+ place: 4,
+ }),
+ ],
+ 'dc.title': [
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'Parent Item'
+ }),
+ ]
+ }
+ });
+ const relatedAuthor: Item = Object.assign(new Item(), {
+ id: 'related-author',
+ metadata: {
+ 'dc.title': [
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'Related Author'
+ }),
+ ]
+ }
+ });
+ const relatedCreator: Item = Object.assign(new Item(), {
+ id: 'related-creator',
+ metadata: {
+ 'dc.title': [
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'Related Creator'
+ }),
+ ],
+ 'dspace.entity.type': 'Person',
+ }
+ });
+ const authorRelation: Relationship = Object.assign(new Relationship(), {
+ leftItem: createSuccessfulRemoteDataObject$(parentItem),
+ rightItem: createSuccessfulRemoteDataObject$(relatedAuthor)
+ });
+ const creatorRelation: Relationship = Object.assign(new Relationship(), {
+ leftItem: createSuccessfulRemoteDataObject$(parentItem),
+ rightItem: createSuccessfulRemoteDataObject$(relatedCreator),
+ });
+ const creatorRelationUnauthorized: Relationship = Object.assign(new Relationship(), {
+ leftItem: createSuccessfulRemoteDataObject$(parentItem),
+ rightItem: createFailedRemoteDataObject$('Unauthorized', 401),
+ });
+
+ let metadatum: MetadataValue;
+
+ beforeEach(() => {
+ service.findById = (id: string) => {
+ if (id === 'related-author') {
+ return createSuccessfulRemoteDataObject$(authorRelation);
+ }
+ if (id === 'related-creator') {
+ return createSuccessfulRemoteDataObject$(creatorRelation);
+ }
+ if (id === 'related-creator-unauthorized') {
+ return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized);
+ }
+ };
+ });
+
+ describe('when the metadata isn\'t virtual', () => {
+ beforeEach(() => {
+ metadatum = parentItem.metadata['dc.contributor.author'][1];
+ });
+
+ it('should return a plain text MetadatumRepresentation', (done) => {
+ service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
+ expect(result.representationType).toEqual(MetadataRepresentationType.PlainText);
+ done();
+ });
+ });
+ });
+
+ describe('when the metadata is a virtual author', () => {
+ beforeEach(() => {
+ metadatum = parentItem.metadata['dc.contributor.author'][0];
+ });
+
+ it('should return a ItemMetadataRepresentation with the correct value', (done) => {
+ service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
+ expect(result.representationType).toEqual(MetadataRepresentationType.Item);
+ expect(result.getValue()).toEqual(metadatum.value);
+ expect((result as any).id).toEqual(relatedAuthor.id);
+ done();
+ });
+ });
+ });
+
+ describe('when the metadata is a virtual creator', () => {
+ beforeEach(() => {
+ metadatum = parentItem.metadata['dc.creator'][0];
+ });
+
+ it('should return a ItemMetadataRepresentation with the correct value', (done) => {
+ service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
+ expect(result.representationType).toEqual(MetadataRepresentationType.Item);
+ expect(result.getValue()).toEqual(metadatum.value);
+ expect((result as any).id).toEqual(relatedCreator.id);
+ done();
+ });
+ });
+ });
+
+ describe('when the metadata refers to a relationship leading to an error response', () => {
+ beforeEach(() => {
+ metadatum = parentItem.metadata['dc.creator'][1];
+ });
+
+ it('should return an authority controlled MetadatumRepresentation', (done) => {
+ service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
+ expect(result.representationType).toEqual(MetadataRepresentationType.AuthorityControlled);
+ done();
+ });
+ });
+ });
+ });
});
diff --git a/src/app/core/data/relationship-data.service.ts b/src/app/core/data/relationship-data.service.ts
index 85adeff754..46a51a2d01 100644
--- a/src/app/core/data/relationship-data.service.ts
+++ b/src/app/core/data/relationship-data.service.ts
@@ -1,7 +1,7 @@
import { HttpHeaders } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
-import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
+import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
import {
compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR,
@@ -46,6 +46,11 @@ import { PutData, PutDataImpl } from './base/put-data';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { dataService } from './base/data-service.decorator';
import { itemLinksToFollow } from '../../shared/utils/relation-query.utils';
+import { MetadataValue } from '../shared/metadata.models';
+import { MetadataRepresentation } from '../shared/metadata-representation/metadata-representation.model';
+import { MetadatumRepresentation } from '../shared/metadata-representation/metadatum/metadatum-representation.model';
+import { ItemMetadataRepresentation } from '../shared/metadata-representation/item/item-metadata-representation.model';
+import { DSpaceObject } from '../shared/dspace-object.model';
const relationshipListsStateSelector = (state: AppState) => state.relationshipLists;
@@ -550,4 +555,40 @@ export class RelationshipDataService extends IdentifiableDataService[]): Observable>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
+
+ /**
+ * Resolve a {@link MetadataValue} into a {@link MetadataRepresentation} of the correct type
+ * @param metadatum {@link MetadataValue} to resolve
+ * @param parentItem Parent dspace object the metadata value belongs to
+ * @param itemType The type of item this metadata value represents (will only be used when no related item can be found, as a fallback)
+ */
+ resolveMetadataRepresentation(metadatum: MetadataValue, parentItem: DSpaceObject, itemType: string): Observable {
+ if (metadatum.isVirtual) {
+ return this.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe(
+ getFirstSucceededRemoteData(),
+ switchMap((relRD: RemoteData) =>
+ observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe(
+ filter(([leftItem, rightItem]) => leftItem.hasCompleted && rightItem.hasCompleted),
+ map(([leftItem, rightItem]) => {
+ if (!leftItem.hasSucceeded || !rightItem.hasSucceeded) {
+ return null;
+ } else if (rightItem.hasSucceeded && leftItem.payload.id === parentItem.id) {
+ return rightItem.payload;
+ } else if (rightItem.payload.id === parentItem.id) {
+ return leftItem.payload;
+ }
+ }),
+ map((item: Item) => {
+ if (hasValue(item)) {
+ return Object.assign(new ItemMetadataRepresentation(metadatum), item);
+ } else {
+ return Object.assign(new MetadatumRepresentation(itemType), metadatum);
+ }
+ })
+ )
+ ));
+ } else {
+ return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum));
+ }
+ }
}
diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts
index 32610c82fd..94ca25235b 100644
--- a/src/app/core/shared/operators.ts
+++ b/src/app/core/shared/operators.ts
@@ -226,7 +226,7 @@ export const metadataFieldsToString = () =>
map((schema: MetadataSchema) => ({ field, schema }))
);
});
- return observableCombineLatest(fieldSchemaArray);
+ return isNotEmpty(fieldSchemaArray) ? observableCombineLatest(fieldSchemaArray) : [[]];
}),
map((fieldSchemaArray: { field: MetadataField, schema: MetadataSchema }[]): string[] => {
return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString());
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html
new file mode 100644
index 0000000000..9f74216d54
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.scss
new file mode 100644
index 0000000000..6b52c4f4b3
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.scss
@@ -0,0 +1,7 @@
+.ds-drop-list {
+ background-color: var(--bs-gray-500);
+
+ &.disabled {
+ opacity: 0.3;
+ }
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts
new file mode 100644
index 0000000000..3ca0dba3b6
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts
@@ -0,0 +1,135 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { VarDirective } from '../../../shared/utils/var.directive';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata-field-values.component';
+import { DsoEditMetadataForm } from '../dso-edit-metadata-form';
+import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+import { MetadataValue } from '../../../core/shared/metadata.models';
+import { of } from 'rxjs/internal/observable/of';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import { By } from '@angular/platform-browser';
+
+describe('DsoEditMetadataFieldValuesComponent', () => {
+ let component: DsoEditMetadataFieldValuesComponent;
+ let fixture: ComponentFixture;
+
+ let form: DsoEditMetadataForm;
+ let dso: DSpaceObject;
+ let mdField: string;
+ let draggingMdField$: BehaviorSubject;
+
+ beforeEach(waitForAsync(() => {
+ dso = Object.assign(new DSpaceObject(), {
+ metadata: {
+ 'dc.title': [
+ Object.assign(new MetadataValue(), {
+ value: 'Test Title',
+ language: 'en',
+ place: 0,
+ }),
+ ],
+ 'dc.subject': [
+ Object.assign(new MetadataValue(), {
+ value: 'Subject One',
+ language: 'en',
+ place: 0,
+ }),
+ Object.assign(new MetadataValue(), {
+ value: 'Subject Two',
+ language: 'en',
+ place: 1,
+ }),
+ Object.assign(new MetadataValue(), {
+ value: 'Subject Three',
+ language: 'en',
+ place: 2,
+ }),
+ ],
+ },
+ });
+ form = new DsoEditMetadataForm(dso.metadata);
+ mdField = 'dc.subject';
+ draggingMdField$ = new BehaviorSubject(null);
+
+ TestBed.configureTestingModule({
+ declarations: [DsoEditMetadataFieldValuesComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DsoEditMetadataFieldValuesComponent);
+ component = fixture.componentInstance;
+ component.dso = dso;
+ component.form = form;
+ component.mdField = mdField;
+ component.saving$ = of(false);
+ component.draggingMdField$ = draggingMdField$;
+ fixture.detectChanges();
+ });
+
+ describe('when draggingMdField$ emits a value equal to mdField', () => {
+ beforeEach(() => {
+ draggingMdField$.next(mdField);
+ fixture.detectChanges();
+ });
+
+ it('should not disable the list', () => {
+ expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull();
+ });
+ });
+
+ describe('when draggingMdField$ emits a value different to mdField', () => {
+ beforeEach(() => {
+ draggingMdField$.next(`${mdField}.fake`);
+ fixture.detectChanges();
+ });
+
+ it('should disable the list', () => {
+ expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeTruthy();
+ });
+ });
+
+ describe('when draggingMdField$ emits null', () => {
+ beforeEach(() => {
+ draggingMdField$.next(null);
+ fixture.detectChanges();
+ });
+
+ it('should not disable the list', () => {
+ expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull();
+ });
+ });
+
+ describe('dropping a value on a different index', () => {
+ beforeEach(() => {
+ component.drop(Object.assign({
+ previousIndex: 0,
+ currentIndex: 2,
+ }));
+ });
+
+ it('should physically move the relevant metadata value within the form', () => {
+ expect(form.fields[mdField][0].newValue.value).toEqual('Subject Two');
+ expect(form.fields[mdField][1].newValue.value).toEqual('Subject Three');
+ expect(form.fields[mdField][2].newValue.value).toEqual('Subject One');
+ });
+
+ it('should update the metadata values their new place to match the new physical order', () => {
+ expect(form.fields[mdField][0].newValue.place).toEqual(0);
+ expect(form.fields[mdField][1].newValue.place).toEqual(1);
+ expect(form.fields[mdField][2].newValue.place).toEqual(2);
+ });
+
+ it('should maintain the metadata values their original place in their original value so it can be used later to determine the patch operations', () => {
+ expect(form.fields[mdField][0].originalValue.place).toEqual(1);
+ expect(form.fields[mdField][1].originalValue.place).toEqual(2);
+ expect(form.fields[mdField][2].originalValue.place).toEqual(0);
+ });
+ });
+});
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts
new file mode 100644
index 0000000000..2702e0ba1f
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts
@@ -0,0 +1,81 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { DsoEditMetadataChangeType, DsoEditMetadataForm, DsoEditMetadataValue } from '../dso-edit-metadata-form';
+import { Observable } from 'rxjs/internal/Observable';
+import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
+
+@Component({
+ selector: 'ds-dso-edit-metadata-field-values',
+ styleUrls: ['./dso-edit-metadata-field-values.component.scss'],
+ templateUrl: './dso-edit-metadata-field-values.component.html',
+})
+/**
+ * Component displaying table rows for each value for a certain metadata field within a form
+ */
+export class DsoEditMetadataFieldValuesComponent {
+ /**
+ * The parent {@link DSpaceObject} to display a metadata form for
+ * Also used to determine metadata-representations in case of virtual metadata
+ */
+ @Input() dso: DSpaceObject;
+ /**
+ * A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm}
+ */
+ @Input() form: DsoEditMetadataForm;
+
+ /**
+ * Metadata field to display values for
+ */
+ @Input() mdField: string;
+
+ /**
+ * Type of DSO we're displaying values for
+ * Determines i18n messages
+ */
+ @Input() dsoType: string;
+
+ /**
+ * Observable to check if the form is being saved or not
+ */
+ @Input() saving$: Observable;
+
+ /**
+ * Tracks for which metadata-field a drag operation is taking place
+ * Null when no drag is currently happening for any field
+ */
+ @Input() draggingMdField$: BehaviorSubject;
+
+ /**
+ * Emit when the value has been saved within the form
+ */
+ @Output() valueSaved: EventEmitter = new EventEmitter();
+
+ /**
+ * The DsoEditMetadataChangeType enumeration for access in the component's template
+ * @type {DsoEditMetadataChangeType}
+ */
+ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
+
+ /**
+ * Drop a value into a new position
+ * Update the form's value array for the current field to match the dropped position
+ * Update the values their place property to match the new order
+ * Send an update to the parent
+ * @param event
+ */
+ drop(event: CdkDragDrop) {
+ const dragIndex = event.previousIndex;
+ const dropIndex = event.currentIndex;
+ // Move the value within its field
+ moveItemInArray(this.form.fields[this.mdField], dragIndex, dropIndex);
+ // Update all the values in this field their place property
+ this.form.fields[this.mdField].forEach((value: DsoEditMetadataValue, index: number) => {
+ value.newValue.place = index;
+ value.confirmChanges();
+ });
+ // Update the form statuses
+ this.form.resetReinstatable();
+ this.valueSaved.emit();
+ }
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts
new file mode 100644
index 0000000000..5f8680088a
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts
@@ -0,0 +1,275 @@
+import { DsoEditMetadataChangeType, DsoEditMetadataForm } from './dso-edit-metadata-form';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { MetadataValue } from '../../core/shared/metadata.models';
+
+describe('DsoEditMetadataForm', () => {
+ let form: DsoEditMetadataForm;
+ let dso: DSpaceObject;
+
+ beforeEach(() => {
+ dso = Object.assign(new DSpaceObject(), {
+ metadata: {
+ 'dc.title': [
+ Object.assign(new MetadataValue(), {
+ value: 'Test Title',
+ language: 'en',
+ place: 0,
+ }),
+ ],
+ 'dc.subject': [
+ Object.assign(new MetadataValue(), {
+ value: 'Subject One',
+ language: 'en',
+ place: 0,
+ }),
+ Object.assign(new MetadataValue(), {
+ value: 'Subject Two',
+ language: 'en',
+ place: 1,
+ }),
+ Object.assign(new MetadataValue(), {
+ value: 'Subject Three',
+ language: 'en',
+ place: 2,
+ }),
+ ],
+ },
+ });
+ form = new DsoEditMetadataForm(dso.metadata);
+ });
+
+
+ describe('adding a new value', () => {
+ beforeEach(() => {
+ form.add();
+ });
+
+ it('should add an empty value to \"newValue\" with no place yet and editing set to true', () => {
+ expect(form.newValue).toBeDefined();
+ expect(form.newValue.originalValue.place).toBeUndefined();
+ expect(form.newValue.newValue.place).toBeUndefined();
+ expect(form.newValue.editing).toBeTrue();
+ });
+
+ it('should not mark the form as changed yet', () => {
+ expect(form.hasChanges()).toEqual(false);
+ });
+
+ describe('and assigning a value and metadata field to it', () => {
+ let mdField: string;
+ let value: string;
+ let expectedPlace: number;
+
+ beforeEach(() => {
+ mdField = 'dc.subject';
+ value = 'Subject Four';
+ form.newValue.newValue.value = value;
+ form.setMetadataField(mdField);
+ expectedPlace = form.fields[mdField].length - 1;
+ });
+
+ it('should add the new value to the values of the relevant field', () => {
+ expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value);
+ });
+
+ it('should set its editing flag to false', () => {
+ expect(form.fields[mdField][expectedPlace].editing).toBeFalse();
+ });
+
+ it('should set both its original and new place to match its position in the value array', () => {
+ expect(form.fields[mdField][expectedPlace].newValue.place).toEqual(expectedPlace);
+ expect(form.fields[mdField][expectedPlace].originalValue.place).toEqual(expectedPlace);
+ });
+
+ it('should clear \"newValue\"', () => {
+ expect(form.newValue).toBeUndefined();
+ });
+
+ it('should mark the form as changed', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+
+ describe('discard', () => {
+ beforeEach(() => {
+ form.discard();
+ });
+
+ it('should remove the new value', () => {
+ expect(form.fields[mdField][expectedPlace]).toBeUndefined();
+ });
+
+ it('should mark the form as unchanged again', () => {
+ expect(form.hasChanges()).toEqual(false);
+ });
+
+ describe('reinstate', () => {
+ beforeEach(() => {
+ form.reinstate();
+ });
+
+ it('should re-add the new value', () => {
+ expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value);
+ });
+
+ it('should mark the form as changed once again', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+ });
+ });
+ });
+ });
+
+ describe('removing a value entirely (not just marking deleted)', () => {
+ it('should remove the value on the correct index', () => {
+ form.remove('dc.subject', 1);
+ expect(form.fields['dc.subject'].length).toEqual(2);
+ expect(form.fields['dc.subject'][0].newValue.value).toEqual('Subject One');
+ expect(form.fields['dc.subject'][1].newValue.value).toEqual('Subject Three');
+ });
+ });
+
+ describe('moving a value', () => {
+ beforeEach(() => {
+ form.fields['dc.subject'][0].newValue.place = form.fields['dc.subject'][1].originalValue.place;
+ form.fields['dc.subject'][1].newValue.place = form.fields['dc.subject'][0].originalValue.place;
+ form.fields['dc.subject'][0].confirmChanges();
+ form.fields['dc.subject'][1].confirmChanges();
+ });
+
+ it('should mark the value as changed', () => {
+ expect(form.fields['dc.subject'][0].hasChanges()).toEqual(true);
+ expect(form.fields['dc.subject'][1].hasChanges()).toEqual(true);
+ });
+
+ it('should mark the form as changed', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+
+ describe('discard', () => {
+ beforeEach(() => {
+ form.discard();
+ });
+
+ it('should reset the moved values their places to their original values', () => {
+ expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place);
+ expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place);
+ });
+
+ it('should mark the form as unchanged again', () => {
+ expect(form.hasChanges()).toEqual(false);
+ });
+
+ describe('reinstate', () => {
+ beforeEach(() => {
+ form.reinstate();
+ });
+
+ it('should move the values to their new places again', () => {
+ expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place);
+ expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place);
+ });
+
+ it('should mark the form as changed once again', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+ });
+ });
+ });
+
+ describe('marking a value deleted', () => {
+ beforeEach(() => {
+ form.fields['dc.title'][0].change = DsoEditMetadataChangeType.REMOVE;
+ });
+
+ it('should mark the value as changed', () => {
+ expect(form.fields['dc.title'][0].hasChanges()).toEqual(true);
+ });
+
+ it('should mark the form as changed', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+
+ describe('discard', () => {
+ beforeEach(() => {
+ form.discard();
+ });
+
+ it('should remove the deleted mark from the value', () => {
+ expect(form.fields['dc.title'][0].change).toBeUndefined();
+ });
+
+ it('should mark the form as unchanged again', () => {
+ expect(form.hasChanges()).toEqual(false);
+ });
+
+ describe('reinstate', () => {
+ beforeEach(() => {
+ form.reinstate();
+ });
+
+ it('should re-mark the value as deleted', () => {
+ expect(form.fields['dc.title'][0].change).toEqual(DsoEditMetadataChangeType.REMOVE);
+ });
+
+ it('should mark the form as changed once again', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+ });
+ });
+ });
+
+ describe('editing a value', () => {
+ const value = 'New title';
+
+ beforeEach(() => {
+ form.fields['dc.title'][0].editing = true;
+ form.fields['dc.title'][0].newValue.value = value;
+ });
+
+ it('should not mark the form as changed yet', () => {
+ expect(form.hasChanges()).toEqual(false);
+ });
+
+ describe('and confirming the changes', () => {
+ beforeEach(() => {
+ form.fields['dc.title'][0].confirmChanges(true);
+ });
+
+ it('should mark the value as changed', () => {
+ expect(form.fields['dc.title'][0].hasChanges()).toEqual(true);
+ });
+
+ it('should mark the form as changed', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+
+ describe('discard', () => {
+ beforeEach(() => {
+ form.discard();
+ });
+
+ it('should reset the changed value to its original value', () => {
+ expect(form.fields['dc.title'][0].newValue.value).toEqual(form.fields['dc.title'][0].originalValue.value);
+ });
+
+ it('should mark the form as unchanged again', () => {
+ expect(form.hasChanges()).toEqual(false);
+ });
+
+ describe('reinstate', () => {
+ beforeEach(() => {
+ form.reinstate();
+ });
+
+ it('should put the changed value back in place', () => {
+ expect(form.fields['dc.title'][0].newValue.value).toEqual(value);
+ });
+
+ it('should mark the form as changed once again', () => {
+ expect(form.hasChanges()).toEqual(true);
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
new file mode 100644
index 0000000000..8206ec9135
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
@@ -0,0 +1,453 @@
+/* eslint-disable max-classes-per-file */
+import { MetadataMap, MetadataValue } from '../../core/shared/metadata.models';
+import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
+import { MoveOperation, Operation } from 'fast-json-patch';
+import { MetadataPatchReplaceOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model';
+import { MetadataPatchRemoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model';
+import { MetadataPatchAddOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model';
+import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
+import { MetadataPatchMoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model';
+
+/**
+ * Enumeration for the type of change occurring on a metadata value
+ */
+export enum DsoEditMetadataChangeType {
+ UPDATE = 1,
+ ADD = 2,
+ REMOVE = 3
+}
+
+/**
+ * Class holding information about a metadata value and its changes within an edit form
+ */
+export class DsoEditMetadataValue {
+ /**
+ * The original metadata value (should stay the same!) used to compare changes with
+ */
+ originalValue: MetadataValue;
+
+ /**
+ * The new value, dynamically changing
+ */
+ newValue: MetadataValue;
+
+ /**
+ * A value that can be used to undo any discarding that took place
+ */
+ reinstatableValue: MetadataValue;
+
+ /**
+ * Whether or not this value is currently being edited or not
+ */
+ editing = false;
+
+ /**
+ * The type of change that's taking place on this metadata value
+ * Empty if no changes are made
+ */
+ change: DsoEditMetadataChangeType;
+
+ /**
+ * A flag to keep track if the value has been reordered (place has changed)
+ */
+ reordered = false;
+
+ /**
+ * A type or change that can be used to undo any discarding that took place
+ */
+ reinstatableChange: DsoEditMetadataChangeType;
+
+ constructor(value: MetadataValue, added = false) {
+ this.originalValue = value;
+ this.newValue = Object.assign(new MetadataValue(), value);
+ if (added) {
+ this.change = DsoEditMetadataChangeType.ADD;
+ this.editing = true;
+ }
+ }
+
+ /**
+ * Save the current changes made to the metadata value
+ * This will set the type of change to UPDATE if the new metadata value's value and/or language are different from
+ * the original value
+ * It will also set the editing flag to false
+ */
+ confirmChanges(finishEditing = false) {
+ this.reordered = this.originalValue.place !== this.newValue.place;
+ if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) {
+ if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) {
+ this.change = DsoEditMetadataChangeType.UPDATE;
+ } else {
+ this.change = undefined;
+ }
+ }
+ if (finishEditing) {
+ this.editing = false;
+ }
+ }
+
+ /**
+ * Returns if the current value contains changes or not
+ * If the metadata value contains changes, but they haven't been confirmed yet through confirmChanges(), this might
+ * return false (which is desired)
+ */
+ hasChanges(): boolean {
+ return hasValue(this.change) || this.reordered;
+ }
+
+ /**
+ * Discard the current changes and mark the value and change type re-instatable by storing them in their relevant
+ * properties
+ */
+ discardAndMarkReinstatable(): void {
+ if (this.change === DsoEditMetadataChangeType.UPDATE || this.reordered) {
+ this.reinstatableValue = this.newValue;
+ }
+ this.reinstatableChange = this.change;
+ this.discard(false);
+ }
+
+ /**
+ * Discard the current changes
+ * Call discardAndMarkReinstatable() instead, if the discard should be re-instatable
+ */
+ discard(keepPlace = true): void {
+ this.change = undefined;
+ const place = this.newValue.place;
+ this.newValue = Object.assign(new MetadataValue(), this.originalValue);
+ if (keepPlace) {
+ this.newValue.place = place;
+ }
+ this.confirmChanges(true);
+ }
+
+ /**
+ * Re-instate (undo) the last discard by replacing the value and change type with their reinstate properties (if present)
+ */
+ reinstate(): void {
+ if (hasValue(this.reinstatableValue)) {
+ this.newValue = this.reinstatableValue;
+ this.reinstatableValue = undefined;
+ }
+ if (hasValue(this.reinstatableChange)) {
+ this.change = this.reinstatableChange;
+ this.reinstatableChange = undefined;
+ }
+ this.confirmChanges();
+ }
+
+ /**
+ * Returns if either the value or change type have a re-instatable property
+ * This will be the case if a discard has taken place that undid changes to the value or type
+ */
+ isReinstatable(): boolean {
+ return hasValue(this.reinstatableValue) || hasValue(this.reinstatableChange);
+ }
+
+ /**
+ * Reset the state of the re-instatable properties
+ */
+ resetReinstatable() {
+ this.reinstatableValue = undefined;
+ this.reinstatableChange = undefined;
+ }
+}
+
+/**
+ * Class holding information about the metadata of a DSpaceObject and its changes within an edit form
+ */
+export class DsoEditMetadataForm {
+ /**
+ * List of original metadata field keys (before any changes took place)
+ */
+ originalFieldKeys: string[];
+
+ /**
+ * List of current metadata field keys (includes new fields for values added by the user)
+ */
+ fieldKeys: string[];
+
+ /**
+ * Current state of the form
+ * Key: Metadata field
+ * Value: List of {@link DsoEditMetadataValue}s for the metadata field
+ */
+ fields: {
+ [mdField: string]: DsoEditMetadataValue[],
+ };
+
+ /**
+ * A map of previously added metadata values before a discard of the form took place
+ * This can be used to re-instate the entire form to before the discard taking place
+ */
+ reinstatableNewValues: {
+ [mdField: string]: DsoEditMetadataValue[],
+ };
+
+ /**
+ * A (temporary) new metadata value added by the user, not belonging to a metadata field yet
+ * This value will be finalised and added to a field using setMetadataField()
+ */
+ newValue: DsoEditMetadataValue;
+
+ constructor(metadata: MetadataMap) {
+ this.originalFieldKeys = [];
+ this.fieldKeys = [];
+ this.fields = {};
+ this.reinstatableNewValues = {};
+ Object.entries(metadata).forEach(([mdField, values]: [string, MetadataValue[]]) => {
+ this.originalFieldKeys.push(mdField);
+ this.fieldKeys.push(mdField);
+ this.setValuesForFieldSorted(mdField, values.map((value: MetadataValue) => new DsoEditMetadataValue(value)));
+ });
+ this.sortFieldKeys();
+ }
+
+ /**
+ * Add a new temporary value for the user to edit
+ */
+ add(): void {
+ if (hasNoValue(this.newValue)) {
+ this.newValue = new DsoEditMetadataValue(new MetadataValue(), true);
+ }
+ }
+
+ /**
+ * Add the temporary value to a metadata field
+ * Clear the temporary value afterwards
+ * @param mdField
+ */
+ setMetadataField(mdField: string): void {
+ this.newValue.editing = false;
+ this.addValueToField(this.newValue, mdField);
+ // Set the place property to match the new value's position within its field
+ const place = this.fields[mdField].length - 1;
+ this.fields[mdField][place].originalValue.place = place;
+ this.fields[mdField][place].newValue.place = place;
+ this.newValue = undefined;
+ }
+
+ /**
+ * Add a value to a metadata field within the map
+ * @param value
+ * @param mdField
+ * @private
+ */
+ private addValueToField(value: DsoEditMetadataValue, mdField: string): void {
+ if (isEmpty(this.fields[mdField])) {
+ this.fieldKeys.push(mdField);
+ this.sortFieldKeys();
+ this.fields[mdField] = [];
+ }
+ this.fields[mdField].push(value);
+ }
+
+ /**
+ * Remove a value from a metadata field on a given index (this actually removes the value, not just marking it deleted)
+ * @param mdField
+ * @param index
+ */
+ remove(mdField: string, index: number): void {
+ if (isNotEmpty(this.fields[mdField])) {
+ this.fields[mdField].splice(index, 1);
+ if (this.fields[mdField].length === 0) {
+ this.fieldKeys.splice(this.fieldKeys.indexOf(mdField), 1);
+ delete this.fields[mdField];
+ }
+ }
+ }
+
+ /**
+ * Returns if at least one value within the form contains a change
+ */
+ hasChanges(): boolean {
+ return Object.values(this.fields).some((values: DsoEditMetadataValue[]) => values.some((value: DsoEditMetadataValue) => value.hasChanges()));
+ }
+
+ /**
+ * Check if a metadata field contains changes within its order (place property of values)
+ * @param mdField
+ */
+ hasOrderChanges(mdField: string): boolean {
+ return this.fields[mdField].some((value: DsoEditMetadataValue) => value.originalValue.place !== value.newValue.place);
+ }
+
+ /**
+ * Discard all changes within the form and store their current values within re-instatable properties so they can be
+ * undone afterwards
+ */
+ discard(): void {
+ this.resetReinstatable();
+ // Discard changes from each value from each field
+ Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
+ let removeFromIndex = -1;
+ values.forEach((value: DsoEditMetadataValue, index: number) => {
+ if (value.change === DsoEditMetadataChangeType.ADD) {
+ if (isEmpty(this.reinstatableNewValues[field])) {
+ this.reinstatableNewValues[field] = [];
+ }
+ this.reinstatableNewValues[field].push(value);
+ if (removeFromIndex === -1) {
+ removeFromIndex = index;
+ }
+ } else {
+ value.discardAndMarkReinstatable();
+ }
+ });
+ if (removeFromIndex > -1) {
+ this.fields[field].splice(removeFromIndex, this.fields[field].length - removeFromIndex);
+ }
+ });
+ // Delete new metadata fields
+ this.fieldKeys.forEach((field: string) => {
+ if (this.originalFieldKeys.indexOf(field) < 0) {
+ delete this.fields[field];
+ }
+ });
+ this.fieldKeys = [...this.originalFieldKeys];
+ this.sortFieldKeys();
+ // Reset the order of values within their fields to match their place property
+ this.fieldKeys.forEach((field: string) => {
+ this.setValuesForFieldSorted(field, this.fields[field]);
+ });
+ }
+
+ /**
+ * Reset the order of values within a metadata field to their original places
+ * Update the actual array to match the place properties
+ * @param mdField
+ */
+ resetOrder(mdField: string) {
+ this.fields[mdField].forEach((value: DsoEditMetadataValue) => {
+ value.newValue.place = value.originalValue.place;
+ value.confirmChanges();
+ });
+ this.setValuesForFieldSorted(mdField, this.fields[mdField]);
+ }
+
+ /**
+ * Sort fieldKeys alphabetically
+ * Should be called whenever a field is added to ensure the alphabetical order is kept
+ */
+ sortFieldKeys() {
+ this.fieldKeys.sort((a: string, b: string) => a.localeCompare(b));
+ }
+
+ /**
+ * Undo any previously discarded changes
+ */
+ reinstate(): void {
+ // Reinstate each value
+ Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => {
+ values.forEach((value: DsoEditMetadataValue) => {
+ value.reinstate();
+ });
+ });
+ // Re-add new values
+ Object.entries(this.reinstatableNewValues).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
+ values.forEach((value: DsoEditMetadataValue) => {
+ this.addValueToField(value, field);
+ });
+ });
+ // Reset the order of values within their fields to match their place property
+ this.fieldKeys.forEach((field: string) => {
+ this.setValuesForFieldSorted(field, this.fields[field]);
+ });
+ this.reinstatableNewValues = {};
+ }
+
+ /**
+ * Returns if at least one value contains a re-instatable property, meaning a discard can be reversed
+ */
+ isReinstatable(): boolean {
+ return isNotEmpty(this.reinstatableNewValues) ||
+ Object.values(this.fields)
+ .some((values: DsoEditMetadataValue[]) => values
+ .some((value: DsoEditMetadataValue) => value.isReinstatable()));
+ }
+
+ /**
+ * Reset the state of the re-instatable properties and values
+ */
+ resetReinstatable(): void {
+ this.reinstatableNewValues = {};
+ Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => {
+ values.forEach((value: DsoEditMetadataValue) => {
+ value.resetReinstatable();
+ });
+ });
+ }
+
+ /**
+ * Set the values of a metadata field and sort them by their newValue's place property
+ * @param mdField
+ * @param values
+ */
+ private setValuesForFieldSorted(mdField: string, values: DsoEditMetadataValue[]) {
+ this.fields[mdField] = values.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place);
+ }
+
+ /**
+ * Get the json PATCH operations for the current changes within this form
+ * For each metadata field, it'll return operations in the following order: replace, remove (from last to first place), add and move
+ * This order is important, as each operation is executed in succession of the previous one
+ */
+ getOperations(moveAnalyser: ArrayMoveChangeAnalyzer): Operation[] {
+ const operations: Operation[] = [];
+ Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
+ const replaceOperations: MetadataPatchReplaceOperation[] = [];
+ const removeOperations: MetadataPatchRemoveOperation[] = [];
+ const addOperations: MetadataPatchAddOperation[] = [];
+ [...values]
+ .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place)
+ .forEach((value: DsoEditMetadataValue) => {
+ if (hasValue(value.change)) {
+ if (value.change === DsoEditMetadataChangeType.UPDATE) {
+ // Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below.
+ if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language) {
+ replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
+ value: value.newValue.value,
+ language: value.newValue.language,
+ }));
+ }
+ } else if (value.change === DsoEditMetadataChangeType.REMOVE) {
+ removeOperations.push(new MetadataPatchRemoveOperation(field, value.originalValue.place));
+ } else if (value.change === DsoEditMetadataChangeType.ADD) {
+ addOperations.push(new MetadataPatchAddOperation(field, {
+ value: value.newValue.value,
+ language: value.newValue.language,
+ }));
+ } else {
+ console.warn('Illegal metadata change state detected for', value);
+ }
+ }
+ });
+
+ operations.push(...replaceOperations
+ .map((operation: MetadataPatchReplaceOperation) => operation.toOperation()));
+ operations.push(...removeOperations
+ // Sort remove operations backwards first, because they get executed in order. This avoids one removal affecting the next.
+ .sort((a: MetadataPatchRemoveOperation, b: MetadataPatchRemoveOperation) => b.place - a.place)
+ .map((operation: MetadataPatchRemoveOperation) => operation.toOperation()));
+ operations.push(...addOperations
+ .map((operation: MetadataPatchAddOperation) => operation.toOperation()));
+ });
+ // Calculate and add the move operations that need to happen in order to move value from their old place to their new within the field
+ // This uses an ArrayMoveChangeAnalyzer
+ Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
+ // Exclude values marked for removal, because operations are executed in order (remove first, then move)
+ const valuesWithoutRemoved = values.filter((value: DsoEditMetadataValue) => value.change !== DsoEditMetadataChangeType.REMOVE);
+ const moveOperations = moveAnalyser
+ .diff(
+ [...valuesWithoutRemoved]
+ .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place)
+ .map((value: DsoEditMetadataValue) => value.originalValue.place),
+ [...valuesWithoutRemoved]
+ .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place)
+ .map((value: DsoEditMetadataValue) => value.originalValue.place))
+ .map((operation: MoveOperation) => new MetadataPatchMoveOperation(field, +operation.from.substr(1), +operation.path.substr(1)).toOperation());
+ operations.push(...moveOperations);
+ });
+ return operations;
+ }
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html
new file mode 100644
index 0000000000..ecaf2aa744
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html
@@ -0,0 +1,10 @@
+
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.scss
new file mode 100644
index 0000000000..d46751d4b0
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.scss
@@ -0,0 +1,12 @@
+.lbl-cell {
+ min-width: var(--ds-dso-edit-field-width);
+ max-width: var(--ds-dso-edit-field-width);
+ background-color: var(--bs-gray-100);
+ font-weight: bold;
+ padding: 1rem;
+ border: 1px solid var(--bs-gray-200);
+}
+
+.ds-header-row {
+ background-color: var(--bs-gray-100);
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts
new file mode 100644
index 0000000000..a0a1da1f1e
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts
@@ -0,0 +1,32 @@
+import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata-headers.component';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { VarDirective } from '../../../shared/utils/var.directive';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { By } from '@angular/platform-browser';
+
+describe('DsoEditMetadataHeadersComponent', () => {
+ let component: DsoEditMetadataHeadersComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [DsoEditMetadataHeadersComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DsoEditMetadataHeadersComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should display three headers', () => {
+ expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(3);
+ });
+});
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.ts
new file mode 100644
index 0000000000..9c21c8ac9e
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.ts
@@ -0,0 +1,17 @@
+import { Component, Input } from '@angular/core';
+
+@Component({
+ selector: 'ds-dso-edit-metadata-headers',
+ styleUrls: ['./dso-edit-metadata-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
+ templateUrl: './dso-edit-metadata-headers.component.html',
+})
+/**
+ * Component displaying the header table row for DSO edit metadata page
+ */
+export class DsoEditMetadataHeadersComponent {
+ /**
+ * Type of DSO we're displaying values for
+ * Determines i18n messages
+ */
+ @Input() dsoType: string;
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss
new file mode 100644
index 0000000000..20d479ac88
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss
@@ -0,0 +1,49 @@
+.ds-field-row {
+ border: 1px solid var(--bs-gray-400);
+}
+
+.ds-flex-cell {
+ padding: 1rem;
+ border: 1px solid var(--bs-gray-200);
+}
+
+.ds-lang-cell {
+ min-width: var(--ds-dso-edit-lang-width);
+ max-width: var(--ds-dso-edit-lang-width);
+}
+
+.ds-edit-cell {
+ min-width: var(--ds-dso-edit-actions-width);
+}
+
+.ds-value-row {
+ background-color: white;
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ &.ds-warning {
+ background-color: var(--bs-warning-bg);
+
+ .ds-flex-cell {
+ border: 1px solid var(--bs-warning);
+ }
+ }
+
+ &.ds-danger {
+ background-color: var(--bs-danger-bg);
+
+ .ds-flex-cell {
+ border: 1px solid var(--bs-danger);
+ }
+ }
+
+ &.ds-success {
+ background-color: var(--bs-success-bg);
+
+ .ds-flex-cell {
+ border: 1px solid var(--bs-success);
+ }
+ }
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html
new file mode 100644
index 0000000000..2c3fb54600
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html
@@ -0,0 +1,5 @@
+
+
{{ dsoType + '.edit.metadata.headers.value' | translate }}
+
{{ dsoType + '.edit.metadata.headers.language' | translate }}
+
{{ dsoType + '.edit.metadata.headers.edit' | translate }}
+
diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.scss
similarity index 100%
rename from src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html
rename to src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.scss
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.ts
new file mode 100644
index 0000000000..dfda2a50d1
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.ts
@@ -0,0 +1,17 @@
+import { Component, Input } from '@angular/core';
+
+@Component({
+ selector: 'ds-dso-edit-metadata-value-headers',
+ styleUrls: ['./dso-edit-metadata-value-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
+ templateUrl: './dso-edit-metadata-value-headers.component.html',
+})
+/**
+ * Component displaying invisible headers for a list of metadata values using table roles for accessibility
+ */
+export class DsoEditMetadataValueHeadersComponent {
+ /**
+ * Type of DSO we're displaying values for
+ * Determines i18n messages
+ */
+ @Input() dsoType: string;
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html
new file mode 100644
index 0000000000..f54dde4971
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html
@@ -0,0 +1,56 @@
+
+
+
{{ mdValue.newValue.value }}
+
+
+
+
+
{{ mdValue.newValue.language }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss
new file mode 100644
index 0000000000..4a207ee1a4
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss
@@ -0,0 +1,16 @@
+.ds-success {
+ background-color: var(--bs-success-bg);
+ border: 1px solid var(--bs-success);
+}
+
+.ds-drag-handle:not(.disabled) {
+ cursor: grab;
+}
+
+::ng-deep .edit-field>ngb-tooltip-window .tooltip-inner {
+ min-width: var(--ds-dso-edit-virtual-tooltip-min-width);
+}
+
+.cdk-drag-placeholder {
+ opacity: 0;
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts
new file mode 100644
index 0000000000..10b3016a52
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts
@@ -0,0 +1,170 @@
+import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { VarDirective } from '../../../shared/utils/var.directive';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
+import { RelationshipDataService } from '../../../core/data/relationship-data.service';
+import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
+import { of } from 'rxjs/internal/observable/of';
+import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
+import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models';
+import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
+import { By } from '@angular/platform-browser';
+
+const EDIT_BTN = 'edit';
+const CONFIRM_BTN = 'confirm';
+const REMOVE_BTN = 'remove';
+const UNDO_BTN = 'undo';
+const DRAG_BTN = 'drag';
+
+describe('DsoEditMetadataValueComponent', () => {
+ let component: DsoEditMetadataValueComponent;
+ let fixture: ComponentFixture;
+
+ let relationshipService: RelationshipDataService;
+ let dsoNameService: DSONameService;
+
+ let editMetadataValue: DsoEditMetadataValue;
+ let metadataValue: MetadataValue;
+
+ function initServices(): void {
+ relationshipService = jasmine.createSpyObj('relationshipService', {
+ resolveMetadataRepresentation: of(new ItemMetadataRepresentation(metadataValue)),
+ });
+ dsoNameService = jasmine.createSpyObj('dsoNameService', {
+ getName: 'Related Name',
+ });
+ }
+
+ beforeEach(waitForAsync(() => {
+ metadataValue = Object.assign(new MetadataValue(), {
+ value: 'Regular Name',
+ language: 'en',
+ place: 0,
+ authority: undefined,
+ });
+ editMetadataValue = new DsoEditMetadataValue(metadataValue);
+
+ initServices();
+
+ TestBed.configureTestingModule({
+ declarations: [DsoEditMetadataValueComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ { provide: RelationshipDataService, useValue: relationshipService },
+ { provide: DSONameService, useValue: dsoNameService },
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DsoEditMetadataValueComponent);
+ component = fixture.componentInstance;
+ component.mdValue = editMetadataValue;
+ component.saving$ = of(false);
+ fixture.detectChanges();
+ });
+
+ it('should not show a badge', () => {
+ expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeNull();
+ });
+
+ describe('when no changes have been made', () => {
+ assertButton(EDIT_BTN, true, false);
+ assertButton(CONFIRM_BTN, false);
+ assertButton(REMOVE_BTN, true, false);
+ assertButton(UNDO_BTN, true, true);
+ assertButton(DRAG_BTN, true, false);
+ });
+
+ describe('when this is the only metadata value within its field', () => {
+ beforeEach(() => {
+ component.isOnlyValue = true;
+ fixture.detectChanges();
+ });
+
+ assertButton(DRAG_BTN, true, true);
+ });
+
+ describe('when the value is marked for removal', () => {
+ beforeEach(() => {
+ editMetadataValue.change = DsoEditMetadataChangeType.REMOVE;
+ fixture.detectChanges();
+ });
+
+ assertButton(REMOVE_BTN, true, true);
+ assertButton(UNDO_BTN, true, false);
+ });
+
+ describe('when the value is being edited', () => {
+ beforeEach(() => {
+ editMetadataValue.editing = true;
+ fixture.detectChanges();
+ });
+
+ assertButton(EDIT_BTN, false);
+ assertButton(CONFIRM_BTN, true, false);
+ assertButton(UNDO_BTN, true, false);
+ });
+
+ describe('when the value is new', () => {
+ beforeEach(() => {
+ editMetadataValue.change = DsoEditMetadataChangeType.ADD;
+ fixture.detectChanges();
+ });
+
+ assertButton(REMOVE_BTN, true, false);
+ assertButton(UNDO_BTN, true, false);
+ });
+
+ describe('when the metadata value is virtual', () => {
+ beforeEach(() => {
+ metadataValue = Object.assign(new MetadataValue(), {
+ value: 'Virtual Name',
+ language: 'en',
+ place: 0,
+ authority: `${VIRTUAL_METADATA_PREFIX}authority-key`,
+ });
+ editMetadataValue = new DsoEditMetadataValue(metadataValue);
+ component.mdValue = editMetadataValue;
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should show a badge', () => {
+ expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeTruthy();
+ });
+
+ assertButton(EDIT_BTN, true, true);
+ assertButton(CONFIRM_BTN, false);
+ assertButton(REMOVE_BTN, true, true);
+ assertButton(UNDO_BTN, true, true);
+ assertButton(DRAG_BTN, true, false);
+ });
+
+ function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
+ describe(`${name} button`, () => {
+ let btn: DebugElement;
+
+ beforeEach(() => {
+ btn = fixture.debugElement.query(By.css(`#metadata-${name}-btn`));
+ });
+
+ if (exists) {
+ it('should exist', () => {
+ expect(btn).toBeTruthy();
+ });
+
+ it(`should${disabled ? ' ' : ' not '}be disabled`, () => {
+ expect(btn.nativeElement.disabled).toBe(disabled);
+ });
+ } else {
+ it('should not exist', () => {
+ expect(btn).toBeNull();
+ });
+ }
+ });
+ }
+});
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts
new file mode 100644
index 0000000000..3fdcd381ab
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts
@@ -0,0 +1,126 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
+import { Observable } from 'rxjs/internal/Observable';
+import {
+ MetadataRepresentation,
+ MetadataRepresentationType
+} from '../../../core/shared/metadata-representation/metadata-representation.model';
+import { RelationshipDataService } from '../../../core/data/relationship-data.service';
+import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
+import { map } from 'rxjs/operators';
+import { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
+import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
+import { EMPTY } from 'rxjs/internal/observable/empty';
+
+@Component({
+ selector: 'ds-dso-edit-metadata-value',
+ styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
+ templateUrl: './dso-edit-metadata-value.component.html',
+})
+/**
+ * Component displaying a single editable row for a metadata value
+ */
+export class DsoEditMetadataValueComponent implements OnInit {
+ /**
+ * The parent {@link DSpaceObject} to display a metadata form for
+ * Also used to determine metadata-representations in case of virtual metadata
+ */
+ @Input() dso: DSpaceObject;
+
+ /**
+ * Editable metadata value to show
+ */
+ @Input() mdValue: DsoEditMetadataValue;
+
+ /**
+ * Type of DSO we're displaying values for
+ * Determines i18n messages
+ */
+ @Input() dsoType: string;
+
+ /**
+ * Observable to check if the form is being saved or not
+ * Will disable certain functionality while saving
+ */
+ @Input() saving$: Observable;
+
+ /**
+ * Is this value the only one within its list?
+ * Will disable certain functionality like dragging (because dragging within a list of 1 is pointless)
+ */
+ @Input() isOnlyValue = false;
+
+ /**
+ * Emits when the user clicked edit
+ */
+ @Output() edit: EventEmitter = new EventEmitter();
+
+ /**
+ * Emits when the user clicked confirm
+ */
+ @Output() confirm: EventEmitter = new EventEmitter();
+
+ /**
+ * Emits when the user clicked remove
+ */
+ @Output() remove: EventEmitter = new EventEmitter();
+
+ /**
+ * Emits when the user clicked undo
+ */
+ @Output() undo: EventEmitter = new EventEmitter();
+
+ /**
+ * Emits true when the user starts dragging a value, false when the user stops dragging
+ */
+ @Output() dragging: EventEmitter = new EventEmitter();
+
+ /**
+ * The DsoEditMetadataChangeType enumeration for access in the component's template
+ * @type {DsoEditMetadataChangeType}
+ */
+ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
+
+ /**
+ * The item this metadata value represents in case it's virtual (if any, otherwise null)
+ */
+ mdRepresentation$: Observable;
+
+ /**
+ * The route to the item represented by this virtual metadata value (otherwise null)
+ */
+ mdRepresentationItemRoute$: Observable;
+
+ /**
+ * The name of the item represented by this virtual metadata value (otherwise null)
+ */
+ mdRepresentationName$: Observable;
+
+ constructor(protected relationshipService: RelationshipDataService,
+ protected dsoNameService: DSONameService) {
+ }
+
+ ngOnInit(): void {
+ this.initVirtualProperties();
+ }
+
+ /**
+ * Initialise potential properties of a virtual metadata value
+ */
+ initVirtualProperties(): void {
+ this.mdRepresentation$ = this.mdValue.newValue.isVirtual ?
+ this.relationshipService.resolveMetadataRepresentation(this.mdValue.newValue, this.dso, 'Item')
+ .pipe(
+ map((mdRepresentation: MetadataRepresentation) =>
+ mdRepresentation.representationType === MetadataRepresentationType.Item ? mdRepresentation as ItemMetadataRepresentation : null
+ )
+ ) : EMPTY;
+ this.mdRepresentationItemRoute$ = this.mdRepresentation$.pipe(
+ map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? getItemPageRoute(mdRepresentation) : null),
+ );
+ this.mdRepresentationName$ = this.mdRepresentation$.pipe(
+ map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null),
+ );
+ }
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html
new file mode 100644
index 0000000000..24c3dc5cd7
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html
@@ -0,0 +1,91 @@
+
+
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss
new file mode 100644
index 0000000000..4e5e9ff1d4
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss
@@ -0,0 +1,21 @@
+.lbl-cell {
+ min-width: var(--ds-dso-edit-field-width);
+ max-width: var(--ds-dso-edit-field-width);
+ background-color: var(--bs-gray-100);
+ font-weight: bold;
+ padding: 1rem;
+ border: 1px solid var(--bs-gray-200);
+
+ &.ds-success {
+ background-color: var(--bs-success-bg);
+ border: 1px solid var(--bs-success);
+ }
+}
+
+.ds-field-row {
+ border: 1px solid var(--bs-gray-400);
+}
+
+.reset-order-button:hover {
+ cursor: pointer;
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts
new file mode 100644
index 0000000000..7067c44fbb
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts
@@ -0,0 +1,193 @@
+import { DsoEditMetadataComponent } from './dso-edit-metadata.component';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { VarDirective } from '../../shared/utils/var.directive';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { DebugElement, Injectable, NO_ERRORS_SCHEMA } from '@angular/core';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { Item } from '../../core/shared/item.model';
+import { MetadataValue } from '../../core/shared/metadata.models';
+import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { By } from '@angular/platform-browser';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
+import { ITEM } from '../../core/shared/item.resource-type';
+import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator';
+import { Operation } from 'fast-json-patch';
+import { RemoteData } from '../../core/data/remote-data';
+import { Observable } from 'rxjs/internal/Observable';
+
+const ADD_BTN = 'add';
+const REINSTATE_BTN = 'reinstate';
+const SAVE_BTN = 'save';
+const DISCARD_BTN = 'discard';
+
+@Injectable()
+class TestDataService {
+ patch(object: Item, operations: Operation[]): Observable> {
+ return createSuccessfulRemoteDataObject$(object);
+ }
+}
+
+describe('DsoEditMetadataComponent', () => {
+ let component: DsoEditMetadataComponent;
+ let fixture: ComponentFixture;
+
+ let notificationsService: NotificationsService;
+
+ let dso: DSpaceObject;
+
+ beforeEach(waitForAsync(() => {
+ dso = Object.assign(new Item(), {
+ type: ITEM,
+ metadata: {
+ 'dc.title': [
+ Object.assign(new MetadataValue(), {
+ value: 'Test Title',
+ language: 'en',
+ place: 0,
+ }),
+ ],
+ 'dc.subject': [
+ Object.assign(new MetadataValue(), {
+ value: 'Subject One',
+ language: 'en',
+ place: 0,
+ }),
+ Object.assign(new MetadataValue(), {
+ value: 'Subject Two',
+ language: 'en',
+ place: 1,
+ }),
+ Object.assign(new MetadataValue(), {
+ value: 'Subject Three',
+ language: 'en',
+ place: 2,
+ }),
+ ],
+ },
+ });
+
+ notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
+
+ TestBed.configureTestingModule({
+ declarations: [DsoEditMetadataComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ TestDataService,
+ { provide: DATA_SERVICE_FACTORY, useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService) },
+ { provide: NotificationsService, useValue: notificationsService },
+ ArrayMoveChangeAnalyzer,
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DsoEditMetadataComponent);
+ component = fixture.componentInstance;
+ component.dso = dso;
+ fixture.detectChanges();
+ });
+
+ describe('when no changes have been made', () => {
+ assertButton(ADD_BTN, true, false);
+ assertButton(REINSTATE_BTN, false);
+ assertButton(SAVE_BTN, true, true);
+ assertButton(DISCARD_BTN, true, true);
+ });
+
+ describe('when the form contains changes', () => {
+ beforeEach(() => {
+ component.form.fields['dc.title'][0].newValue.value = 'Updated Title Once';
+ component.form.fields['dc.title'][0].confirmChanges();
+ component.form.resetReinstatable();
+ component.onValueSaved();
+ fixture.detectChanges();
+ });
+
+ assertButton(SAVE_BTN, true, false);
+ assertButton(DISCARD_BTN, true, false);
+
+ describe('and they were discarded', () => {
+ beforeEach(() => {
+ component.discard();
+ fixture.detectChanges();
+ });
+
+ assertButton(REINSTATE_BTN, true, false);
+ assertButton(SAVE_BTN, true, true);
+ assertButton(DISCARD_BTN, false);
+
+ describe('and a new change is made', () => {
+ beforeEach(() => {
+ component.form.fields['dc.title'][0].newValue.value = 'Updated Title Twice';
+ component.form.fields['dc.title'][0].confirmChanges();
+ component.form.resetReinstatable();
+ component.onValueSaved();
+ fixture.detectChanges();
+ });
+
+ assertButton(REINSTATE_BTN, false);
+ assertButton(SAVE_BTN, true, false);
+ assertButton(DISCARD_BTN, true, false);
+ });
+ });
+ });
+
+ describe('when a new value is present', () => {
+ beforeEach(() => {
+ component.add();
+ fixture.detectChanges();
+ });
+
+ assertButton(ADD_BTN, true, true);
+
+ it('should display a row with a field selector and metadata value', () => {
+ expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeTruthy();
+ expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeTruthy();
+ });
+
+ describe('and gets assigned to a metadata field', () => {
+ beforeEach(() => {
+ component.form.newValue.newValue.value = 'New Subject';
+ component.form.setMetadataField('dc.subject');
+ component.form.resetReinstatable();
+ component.onValueSaved();
+ fixture.detectChanges();
+ });
+
+ assertButton(ADD_BTN, true, false);
+
+ it('should not display the separate row with field selector and metadata value anymore', () => {
+ expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeNull();
+ expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeNull();
+ });
+ });
+ });
+
+ function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
+ describe(`${name} button`, () => {
+ let btn: DebugElement;
+
+ beforeEach(() => {
+ btn = fixture.debugElement.query(By.css(`#dso-${name}-btn`));
+ });
+
+ if (exists) {
+ it('should exist', () => {
+ expect(btn).toBeTruthy();
+ });
+
+ it(`should${disabled ? ' ' : ' not '}be disabled`, () => {
+ expect(btn.nativeElement.disabled).toBe(disabled);
+ });
+ } else {
+ it('should not exist', () => {
+ expect(btn).toBeNull();
+ });
+ }
+ });
+ }
+
+});
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts
new file mode 100644
index 0000000000..d67a7ea738
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts
@@ -0,0 +1,261 @@
+import { Component, Inject, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { AlertType } from '../../shared/alert/aletr-type';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { DsoEditMetadataForm } from './dso-edit-metadata-form';
+import { map } from 'rxjs/operators';
+import { ActivatedRoute, Data } from '@angular/router';
+import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
+import { Subscription } from 'rxjs/internal/Subscription';
+import { RemoteData } from '../../core/data/remote-data';
+import { hasNoValue, hasValue } from '../../shared/empty.util';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import {
+ getFirstCompletedRemoteData,
+} from '../../core/shared/operators';
+import { UpdateDataService } from '../../core/data/update-data.service';
+import { ResourceType } from '../../core/shared/resource-type';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { TranslateService } from '@ngx-translate/core';
+import { MetadataFieldSelectorComponent } from './metadata-field-selector/metadata-field-selector.component';
+import { Observable } from 'rxjs/internal/Observable';
+import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
+import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator';
+import { GenericConstructor } from '../../core/shared/generic-constructor';
+import { HALDataService } from '../../core/data/base/hal-data-service.interface';
+
+@Component({
+ selector: 'ds-dso-edit-metadata',
+ styleUrls: ['./dso-edit-metadata.component.scss'],
+ templateUrl: './dso-edit-metadata.component.html',
+})
+/**
+ * Component showing a table of all metadata on a DSpaceObject and options to modify them
+ */
+export class DsoEditMetadataComponent implements OnInit, OnDestroy {
+ /**
+ * DSpaceObject to edit metadata for
+ */
+ @Input() dso: DSpaceObject;
+
+ /**
+ * Reference to the component responsible for showing a metadata-field selector
+ * Used to validate its contents (existing metadata field) before adding a new metadata value
+ */
+ @ViewChild(MetadataFieldSelectorComponent) metadataFieldSelectorComponent: MetadataFieldSelectorComponent;
+
+ /**
+ * Resolved update data-service for the given DSpaceObject (depending on its type, e.g. ItemDataService for an Item)
+ * Used to send the PATCH request
+ */
+ @Input() updateDataService: UpdateDataService;
+
+ /**
+ * Type of the DSpaceObject in String
+ * Used to resolve i18n messages
+ */
+ dsoType: string;
+
+ /**
+ * A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm}
+ */
+ form: DsoEditMetadataForm;
+
+ /**
+ * The metadata field entered by the user for a new metadata value
+ */
+ newMdField: string;
+
+ // Properties determined by the state of the dynamic form, updated by onValueSaved()
+ isReinstatable: boolean;
+ hasChanges: boolean;
+ isEmpty: boolean;
+
+ /**
+ * Whether or not the form is currently being submitted
+ */
+ saving$: BehaviorSubject = new BehaviorSubject(false);
+
+ /**
+ * Tracks for which metadata-field a drag operation is taking place
+ * Null when no drag is currently happening for any field
+ * This is a BehaviorSubject that is passed down to child components, to give them the power to alter the state
+ */
+ draggingMdField$: BehaviorSubject = new BehaviorSubject(null);
+
+ /**
+ * Whether or not the metadata field is currently being validated
+ */
+ loadingFieldValidation$: BehaviorSubject = new BehaviorSubject(false);
+
+ /**
+ * Combination of saving$ and loadingFieldValidation$
+ * Emits true when any of the two emit true
+ */
+ savingOrLoadingFieldValidation$: Observable;
+
+ /**
+ * The AlertType enumeration for access in the component's template
+ * @type {AlertType}
+ */
+ public AlertTypeEnum = AlertType;
+
+ /**
+ * Subscription for updating the current DSpaceObject
+ * Unsubscribed from in ngOnDestroy()
+ */
+ dsoUpdateSubscription: Subscription;
+
+ constructor(protected route: ActivatedRoute,
+ protected notificationsService: NotificationsService,
+ protected translateService: TranslateService,
+ protected parentInjector: Injector,
+ protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer,
+ @Inject(DATA_SERVICE_FACTORY) protected getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>) {
+ }
+
+ /**
+ * Read the route (or parent route)'s data to retrieve the current DSpaceObject
+ * After it's retrieved, initialise the data-service and form
+ */
+ ngOnInit(): void {
+ if (hasNoValue(this.dso)) {
+ this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe(
+ map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)),
+ map((data: any) => data.dso)
+ ).subscribe((rd: RemoteData) => {
+ this.dso = rd.payload;
+ this.initDataService();
+ this.initForm();
+ });
+ } else {
+ this.initDataService();
+ this.initForm();
+ }
+ this.savingOrLoadingFieldValidation$ = observableCombineLatest([this.saving$, this.loadingFieldValidation$]).pipe(
+ map(([saving, loading]: [boolean, boolean]) => saving || loading),
+ );
+ }
+
+ /**
+ * Initialise (resolve) the data-service for the current DSpaceObject
+ */
+ initDataService(): void {
+ let type: ResourceType;
+ if (typeof this.dso.type === 'string') {
+ type = new ResourceType(this.dso.type);
+ } else {
+ type = this.dso.type;
+ }
+ if (hasNoValue(this.updateDataService)) {
+ const provider = this.getDataServiceFor(type);
+ this.updateDataService = Injector.create({
+ providers: [],
+ parent: this.parentInjector
+ }).get(provider);
+ }
+ this.dsoType = type.value;
+ }
+
+ /**
+ * Initialise the dynamic form object by passing the DSpaceObject's metadata
+ * Call onValueSaved() to update the form's state properties
+ */
+ initForm(): void {
+ this.form = new DsoEditMetadataForm(this.dso.metadata);
+ this.onValueSaved();
+ }
+
+ /**
+ * Update the form's state properties
+ */
+ onValueSaved(): void {
+ this.hasChanges = this.form.hasChanges();
+ this.isReinstatable = this.form.isReinstatable();
+ this.isEmpty = Object.keys(this.form.fields).length === 0;
+ }
+
+ /**
+ * Submit the current changes to the form by retrieving json PATCH operations from the form and sending it to the
+ * DSpaceObject's data-service
+ * Display notificiations and reset the form afterwards if successful
+ */
+ submit(): void {
+ this.saving$.next(true);
+ this.updateDataService.patch(this.dso, this.form.getOperations(this.arrayMoveChangeAnalyser)).pipe(
+ getFirstCompletedRemoteData()
+ ).subscribe((rd: RemoteData) => {
+ this.saving$.next(false);
+ if (rd.hasFailed) {
+ this.notificationsService.error(this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.error.title`), rd.errorMessage);
+ } else {
+ this.notificationsService.success(
+ this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.saved.title`),
+ this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.saved.content`)
+ );
+ this.dso = rd.payload;
+ this.initForm();
+ }
+ });
+ }
+
+ /**
+ * Confirm the newly added value
+ * @param saved Whether or not the value was manually saved (only then, add the value to its metadata field)
+ */
+ confirmNewValue(saved: boolean): void {
+ if (saved) {
+ this.setMetadataField();
+ }
+ }
+
+ /**
+ * Set the metadata field of the temporary added new metadata value
+ * This will move the new value to its respective parent metadata field
+ * Validate the metadata field first
+ */
+ setMetadataField(): void {
+ this.form.resetReinstatable();
+ this.loadingFieldValidation$.next(true);
+ this.metadataFieldSelectorComponent.validate().subscribe((valid: boolean) => {
+ this.loadingFieldValidation$.next(false);
+ if (valid) {
+ this.form.setMetadataField(this.newMdField);
+ this.onValueSaved();
+ }
+ });
+ }
+
+ /**
+ * Add a new temporary metadata value
+ */
+ add(): void {
+ this.newMdField = undefined;
+ this.form.add();
+ }
+
+ /**
+ * Discard all changes within the current form
+ */
+ discard(): void {
+ this.form.discard();
+ this.onValueSaved();
+ }
+
+ /**
+ * Restore any changes previously discarded from the form
+ */
+ reinstate(): void {
+ this.form.reinstate();
+ this.onValueSaved();
+ }
+
+ /**
+ * Unsubscribe from any open subscriptions
+ */
+ ngOnDestroy(): void {
+ if (hasValue(this.dsoUpdateSubscription)) {
+ this.dsoUpdateSubscription.unsubscribe();
+ }
+ }
+
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html
new file mode 100644
index 0000000000..4c310bd81b
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html
@@ -0,0 +1,19 @@
+
+
+
{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}
+
+
diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.scss
similarity index 100%
rename from src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss
rename to src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.scss
diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts
new file mode 100644
index 0000000000..e0fde0e8f2
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts
@@ -0,0 +1,122 @@
+import { MetadataFieldSelectorComponent } from './metadata-field-selector.component';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { VarDirective } from '../../../shared/utils/var.directive';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { RegistryService } from '../../../core/registry/registry.service';
+import { MetadataField } from '../../../core/metadata/metadata-field.model';
+import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
+import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
+import { createPaginatedList } from '../../../shared/testing/utils.test';
+import { followLink } from '../../../shared/utils/follow-link-config.model';
+import { By } from '@angular/platform-browser';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+
+describe('MetadataFieldSelectorComponent', () => {
+ let component: MetadataFieldSelectorComponent;
+ let fixture: ComponentFixture;
+
+ let registryService: RegistryService;
+ let notificationsService: NotificationsService;
+
+ let metadataSchema: MetadataSchema;
+ let metadataFields: MetadataField[];
+
+ beforeEach(waitForAsync(() => {
+ metadataSchema = Object.assign(new MetadataSchema(), {
+ id: 0,
+ prefix: 'dc',
+ namespace: 'http://dublincore.org/documents/dcmi-terms/',
+ });
+ metadataFields = [
+ Object.assign(new MetadataField(), {
+ id: 0,
+ element: 'description',
+ qualifier: undefined,
+ schema: createSuccessfulRemoteDataObject$(metadataSchema),
+ }),
+ Object.assign(new MetadataField(), {
+ id: 1,
+ element: 'description',
+ qualifier: 'abstract',
+ schema: createSuccessfulRemoteDataObject$(metadataSchema),
+ }),
+ ];
+
+ registryService = jasmine.createSpyObj('registryService', {
+ queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)),
+ });
+ notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
+
+ TestBed.configureTestingModule({
+ declarations: [MetadataFieldSelectorComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ { provide: RegistryService, useValue: registryService },
+ { provide: NotificationsService, useValue: notificationsService },
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataFieldSelectorComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ describe('when a query is entered', () => {
+ const query = 'test query';
+
+ beforeEach(() => {
+ component.showInvalid = true;
+ component.query$.next(query);
+ });
+
+ it('should reset showInvalid', () => {
+ expect(component.showInvalid).toBeFalse();
+ });
+
+ it('should query the registry service for metadata fields and include the schema', () => {
+ expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, null, true, false, followLink('schema'));
+ });
+ });
+
+ describe('validate', () => {
+ it('should return an observable true and show no feedback if the current mdField exists in registry', (done) => {
+ component.mdField = 'dc.description.abstract';
+ component.validate().subscribe((result) => {
+ expect(result).toBeTrue();
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeNull();
+ done();
+ });
+ });
+
+ it('should return an observable false and show invalid feedback if the current mdField is missing in registry', (done) => {
+ component.mdField = 'dc.fake.field';
+ component.validate().subscribe((result) => {
+ expect(result).toBeFalse();
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeTruthy();
+ done();
+ });
+ });
+
+ describe('when querying the metadata fields returns an error response', () => {
+ beforeEach(() => {
+ (registryService.queryMetadataFields as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Failed'));
+ });
+
+ it('should return an observable false and show a notification', (done) => {
+ component.mdField = 'dc.description.abstract';
+ component.validate().subscribe((result) => {
+ expect(result).toBeFalse();
+ expect(notificationsService.error).toHaveBeenCalled();
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts
new file mode 100644
index 0000000000..5053a4b83d
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts
@@ -0,0 +1,188 @@
+import {
+ AfterViewInit,
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ OnInit,
+ Output,
+ ViewChild
+} from '@angular/core';
+import { switchMap, debounceTime, distinctUntilChanged, map, tap, take } from 'rxjs/operators';
+import { followLink } from '../../../shared/utils/follow-link-config.model';
+import {
+ getAllSucceededRemoteData, getFirstCompletedRemoteData,
+ metadataFieldsToString
+} from '../../../core/shared/operators';
+import { Observable } from 'rxjs/internal/Observable';
+import { RegistryService } from '../../../core/registry/registry.service';
+import { FormControl } from '@angular/forms';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import { hasValue } from '../../../shared/empty.util';
+import { Subscription } from 'rxjs/internal/Subscription';
+import { of } from 'rxjs/internal/observable/of';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { TranslateService } from '@ngx-translate/core';
+
+@Component({
+ selector: 'ds-metadata-field-selector',
+ styleUrls: ['./metadata-field-selector.component.scss'],
+ templateUrl: './metadata-field-selector.component.html'
+})
+/**
+ * Component displaying a searchable input for metadata-fields
+ */
+export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterViewInit {
+ /**
+ * Type of the DSpaceObject
+ * Used to resolve i18n messages
+ */
+ @Input() dsoType: string;
+
+ /**
+ * The currently entered metadata field
+ */
+ @Input() mdField: string;
+
+ /**
+ * If true, the input will be automatically focussed upon when the component is first loaded
+ */
+ @Input() autofocus = false;
+
+ /**
+ * Emit any changes made to the metadata field
+ * This will only emit after a debounce takes place to avoid constant emits when the user is typing
+ */
+ @Output() mdFieldChange = new EventEmitter();
+
+ /**
+ * Reference to the metadata-field's input
+ */
+ @ViewChild('mdFieldInput', { static: true }) mdFieldInput: ElementRef;
+
+ /**
+ * List of available metadata field options to choose from, dependent on the current query the user entered
+ * Shows up in a dropdown below the input
+ */
+ mdFieldOptions$: Observable;
+
+ /**
+ * FormControl for the input
+ */
+ public input: FormControl = new FormControl();
+
+ /**
+ * The current query to update mdFieldOptions$ for
+ * This is controlled by a debounce, to avoid too many requests
+ */
+ query$: BehaviorSubject = new BehaviorSubject(null);
+
+ /**
+ * The amount of time to debounce the query for (in ms)
+ */
+ debounceTime = 300;
+
+ /**
+ * Whether or not the the user just selected a value
+ * This flag avoids the metadata field from updating twice, which would result in the dropdown opening again right after selecting a value
+ */
+ selectedValueLoading = false;
+
+ /**
+ * Whether or not to show the invalid feedback
+ * True when validate() is called and the mdField isn't present in the available metadata fields retrieved from the server
+ */
+ showInvalid = false;
+
+ /**
+ * Subscriptions to unsubscribe from on destroy
+ */
+ subs: Subscription[] = [];
+
+ constructor(protected registryService: RegistryService,
+ protected notificationsService: NotificationsService,
+ protected translate: TranslateService) {
+ }
+
+ /**
+ * Subscribe to any changes made to the input, with a debounce and fire a query, as well as emit the change from this component
+ * Update the mdFieldOptions$ depending on the query$ fired by querying the server
+ */
+ ngOnInit(): void {
+ this.subs.push(
+ this.input.valueChanges.pipe(
+ debounceTime(this.debounceTime),
+ ).subscribe((valueChange) => {
+ if (!this.selectedValueLoading) {
+ this.query$.next(valueChange);
+ }
+ this.selectedValueLoading = false;
+ this.mdField = valueChange;
+ this.mdFieldChange.emit(this.mdField);
+ }),
+ );
+ this.mdFieldOptions$ = this.query$.pipe(
+ distinctUntilChanged(),
+ switchMap((query: string) => {
+ this.showInvalid = false;
+ if (query !== null) {
+ return this.registryService.queryMetadataFields(query, null, true, false, followLink('schema')).pipe(
+ getAllSucceededRemoteData(),
+ metadataFieldsToString(),
+ );
+ } else {
+ return [[]];
+ }
+ }),
+ );
+ }
+
+ /**
+ * Focus the input if autofocus is enabled
+ */
+ ngAfterViewInit(): void {
+ if (this.autofocus) {
+ this.mdFieldInput.nativeElement.focus();
+ }
+ }
+
+ /**
+ * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error
+ * Upon subscribing to the returned observable, the showInvalid flag is updated accordingly to show the feedback under the input
+ */
+ validate(): Observable {
+ return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
+ getFirstCompletedRemoteData(),
+ switchMap((rd) => {
+ if (rd.hasSucceeded) {
+ return of(rd).pipe(
+ metadataFieldsToString(),
+ take(1),
+ map((fields: string[]) => fields.indexOf(this.mdField) > -1),
+ tap((exists: boolean) => this.showInvalid = !exists),
+ );
+ } else {
+ this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage);
+ return [false];
+ }
+ }),
+ );
+ }
+
+ /**
+ * Select a metadata field from the dropdown options
+ * @param mdFieldOption
+ */
+ select(mdFieldOption: string): void {
+ this.selectedValueLoading = true;
+ this.input.setValue(mdFieldOption);
+ }
+
+ /**
+ * Unsubscribe from any open subscriptions
+ */
+ ngOnDestroy(): void {
+ this.subs.filter((sub: Subscription) => hasValue(sub)).forEach((sub: Subscription) => sub.unsubscribe());
+ }
+}
diff --git a/src/app/dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component.ts
new file mode 100644
index 0000000000..ba21907c99
--- /dev/null
+++ b/src/app/dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component.ts
@@ -0,0 +1,33 @@
+import { ThemedComponent } from '../../shared/theme-support/themed.component';
+import { DsoEditMetadataComponent } from './dso-edit-metadata.component';
+import { Component, Input } from '@angular/core';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { UpdateDataService } from '../../core/data/update-data.service';
+
+@Component({
+ selector: 'ds-themed-dso-edit-metadata',
+ styleUrls: [],
+ templateUrl: './../../shared/theme-support/themed.component.html',
+})
+export class ThemedDsoEditMetadataComponent extends ThemedComponent {
+
+ @Input() dso: DSpaceObject;
+
+ @Input() updateDataService: UpdateDataService;
+
+ protected inAndOutputNames: (keyof DsoEditMetadataComponent & keyof this)[] = ['dso', 'updateDataService'];
+
+ protected getComponentName(): string {
+ return 'DsoEditMetadataComponent';
+ }
+
+ protected importThemedComponent(themeName: string): Promise {
+ return import(`../../../themes/${themeName}/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component`);
+ }
+
+ protected importUnthemedComponent(): Promise {
+ return import(`./dso-edit-metadata.component`);
+ }
+
+
+}
diff --git a/src/app/dso-shared/dso-shared.module.ts b/src/app/dso-shared/dso-shared.module.ts
new file mode 100644
index 0000000000..7d44d6a920
--- /dev/null
+++ b/src/app/dso-shared/dso-shared.module.ts
@@ -0,0 +1,36 @@
+import { NgModule } from '@angular/core';
+import { SharedModule } from '../shared/shared.module';
+import { DsoEditMetadataComponent } from './dso-edit-metadata/dso-edit-metadata.component';
+import { MetadataFieldSelectorComponent } from './dso-edit-metadata/metadata-field-selector/metadata-field-selector.component';
+import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component';
+import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component';
+import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component';
+import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component';
+import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-edit-metadata.component';
+
+@NgModule({
+ imports: [
+ SharedModule,
+ ],
+ declarations: [
+ DsoEditMetadataComponent,
+ ThemedDsoEditMetadataComponent,
+ MetadataFieldSelectorComponent,
+ DsoEditMetadataFieldValuesComponent,
+ DsoEditMetadataValueComponent,
+ DsoEditMetadataHeadersComponent,
+ DsoEditMetadataValueHeadersComponent,
+ ],
+ exports: [
+ DsoEditMetadataComponent,
+ ThemedDsoEditMetadataComponent,
+ MetadataFieldSelectorComponent,
+ DsoEditMetadataFieldValuesComponent,
+ DsoEditMetadataValueComponent,
+ DsoEditMetadataHeadersComponent,
+ DsoEditMetadataValueHeadersComponent,
+ ],
+})
+export class DsoSharedModule {
+
+}
diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html
index 88236d381e..97265d7a23 100644
--- a/src/app/footer/footer.component.html
+++ b/src/app/footer/footer.component.html
@@ -64,7 +64,7 @@