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/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 index 6a0386da8e..190d5a7e31 100644 --- 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 @@ -1,4 +1,4 @@ -
+
+ (undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()" + (dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)">
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 index afe5084bc2..6b52c4f4b3 100644 --- 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 @@ -1,3 +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.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts index 0db427c218..2702e0ba1f 100644 --- 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 @@ -1,7 +1,9 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { DsoEditMetadataChangeType, DsoEditMetadataForm } from '../dso-edit-metadata-form'; +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', @@ -38,6 +40,12 @@ export class DsoEditMetadataFieldValuesComponent { */ @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 */ @@ -48,4 +56,26 @@ export class DsoEditMetadataFieldValuesComponent { * @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.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts index fde0ecf9d4..cc1f55838b 100644 --- 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 @@ -1,10 +1,11 @@ import { MetadataMap, MetadataValue } from '../../core/shared/metadata.models'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { Operation } from 'fast-json-patch'; +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 { MetadataPatchOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-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'; /* tslint:disable:max-classes-per-file */ @@ -69,7 +70,7 @@ export class DsoEditMetadataValue { */ confirmChanges(finishEditing = false) { if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) { - if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) { + if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language || this.originalValue.place !== this.newValue.place)) { this.change = DsoEditMetadataChangeType.UPDATE; } else { this.change = undefined; @@ -187,7 +188,7 @@ export class DsoEditMetadataForm { Object.entries(metadata).forEach(([mdField, values]: [string, MetadataValue[]]) => { this.originalFieldKeys.push(mdField); this.fieldKeys.push(mdField); - this.fields[mdField] = values.map((value: MetadataValue) => new DsoEditMetadataValue(value)); + this.setValuesForFieldSorted(mdField, values.map((value: MetadataValue) => new DsoEditMetadataValue(value))); }); } @@ -208,6 +209,10 @@ export class DsoEditMetadataForm { 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; } @@ -253,6 +258,7 @@ export class DsoEditMetadataForm { */ 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) => { @@ -272,25 +278,39 @@ export class DsoEditMetadataForm { 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]; + // Reset the order of values within their fields to match their place property + this.fieldKeys.forEach((field: string) => { + this.setValuesForFieldSorted(field, this.fields[field]); + }); } + /** + * 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 = {}; } @@ -317,34 +337,73 @@ export class DsoEditMetadataForm { } /** - * Get the json PATCH operations for the current changes within this form + * Set the values of a metadata field and sort them by their newValue's place property + * @param mdField + * @param values */ - getOperations(): Operation[] { + 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[]]) => { - values.forEach((value: DsoEditMetadataValue, place: number) => { - if (value.hasChanges()) { - let operation: MetadataPatchOperation; - if (value.change === DsoEditMetadataChangeType.UPDATE) { - operation = new MetadataPatchReplaceOperation(field, place, { - value: value.newValue.value, - language: value.newValue.language, - }); - } else if (value.change === DsoEditMetadataChangeType.REMOVE) { - operation = new MetadataPatchRemoveOperation(field, place); - } else if (value.change === DsoEditMetadataChangeType.ADD) { - operation = new MetadataPatchAddOperation(field, { - value: value.newValue.value, - language: value.newValue.language, - }); - } else { - console.warn('Illegal metadata change state detected for', value); + 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 (value.hasChanges()) { + 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); + } } - if (hasValue(operation)) { - operations.push(operation.toOperation()); - } - } - }); + }); + + 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 + .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-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 index e72a9e8444..9941d03308 100644 --- 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 @@ -1,4 +1,5 @@
{{ mdValue.newValue.value }}
@@ -34,8 +35,7 @@
- - 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 index 1196aa1940..bcf491b9ce 100644 --- 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 @@ -10,3 +10,7 @@ ::ng-deep .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.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index b61255e4fe..dce0ec68b0 100644 --- 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 @@ -71,6 +71,11 @@ export class DsoEditMetadataValueComponent implements OnInit { */ @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} 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 index 0ca62ff1b6..e498dc373e 100644 --- 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 @@ -48,6 +48,7 @@ [form]="form" [dsoType]="dsoType" [saving$]="saving$" + [draggingMdField$]="draggingMdField$" [mdField]="mdField" (valueSaved)="onValueSaved()"> 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 index e98cc3663d..7f98720b2c 100644 --- 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 @@ -19,6 +19,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s 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'; @Component({ selector: 'ds-dso-edit-metadata', @@ -72,6 +73,13 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { */ 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 */ @@ -98,7 +106,8 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { constructor(protected route: ActivatedRoute, protected notificationsService: NotificationsService, protected translateService: TranslateService, - protected parentInjector: Injector) { + protected parentInjector: Injector, + protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer) { } /** @@ -167,7 +176,7 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { */ submit(): void { this.saving$.next(true); - this.updateDataService.patch(this.dso, this.form.getOperations()).pipe( + this.updateDataService.patch(this.dso, this.form.getOperations(this.arrayMoveChangeAnalyser)).pipe( getFirstCompletedRemoteData() ).subscribe((rd: RemoteData) => { this.saving$.next(false);