-
+
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/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 @@
+