Skip to content

Commit 64d9645

Browse files
93747: Edit metadata redesign - drag to reorder pt1
1 parent 43d9e3f commit 64d9645

10 files changed

Lines changed: 180 additions & 34 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { MetadataPatchOperation } from './metadata-patch-operation.model';
2+
import { Operation } from 'fast-json-patch';
3+
4+
/**
5+
* Wrapper object for a metadata patch move Operation
6+
*/
7+
export class MetadataPatchMoveOperation extends MetadataPatchOperation {
8+
static operationType = 'move';
9+
10+
/**
11+
* The original place of the metadata value to move
12+
*/
13+
from: number;
14+
15+
/**
16+
* The new place to move the metadata value to
17+
*/
18+
to: number;
19+
20+
constructor(field: string, from: number, to: number) {
21+
super(MetadataPatchMoveOperation.operationType, field);
22+
this.from = from;
23+
this.to = to;
24+
}
25+
26+
/**
27+
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
28+
* using the information provided.
29+
*/
30+
toOperation(): Operation {
31+
return { op: this.op as any, from: `/metadata/${this.field}/${this.from}`, path: `/metadata/${this.field}/${this.to}` };
32+
}
33+
}
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div class="flex-grow-1 ds-drop-list h-100">
1+
<div class="flex-grow-1 ds-drop-list h-100" [class.disabled]="(draggingMdField$ | async) && (draggingMdField$ | async) !== mdField" cdkDropList (cdkDropListDropped)="drop($event)">
22
<ds-dso-edit-metadata-value *ngFor="let mdValue of form.fields[mdField]; let idx = index"
33
[dso]="dso"
44
[mdValue]="mdValue"
@@ -8,6 +8,7 @@
88
(edit)="mdValue.editing = true"
99
(confirm)="mdValue.confirmChanges($event); form.resetReinstatable(); valueSaved.emit()"
1010
(remove)="mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; form.resetReinstatable(); valueSaved.emit()"
11-
(undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()">
11+
(undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()"
12+
(dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)">
1213
</ds-dso-edit-metadata-value>
1314
</div>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
.ds-drop-list {
22
background-color: var(--bs-gray-500);
3+
4+
&.disabled {
5+
opacity: 0.3;
6+
}
37
}

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Component, EventEmitter, Input, Output } from '@angular/core';
2-
import { DsoEditMetadataChangeType, DsoEditMetadataForm } from '../dso-edit-metadata-form';
2+
import { DsoEditMetadataChangeType, DsoEditMetadataForm, DsoEditMetadataValue } from '../dso-edit-metadata-form';
33
import { Observable } from 'rxjs/internal/Observable';
44
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
5+
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
6+
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
57

68
@Component({
79
selector: 'ds-dso-edit-metadata-field-values',
@@ -38,6 +40,12 @@ export class DsoEditMetadataFieldValuesComponent {
3840
*/
3941
@Input() saving$: Observable<boolean>;
4042

43+
/**
44+
* Tracks for which metadata-field a drag operation is taking place
45+
* Null when no drag is currently happening for any field
46+
*/
47+
@Input() draggingMdField$: BehaviorSubject<string>;
48+
4149
/**
4250
* Emit when the value has been saved within the form
4351
*/
@@ -48,4 +56,26 @@ export class DsoEditMetadataFieldValuesComponent {
4856
* @type {DsoEditMetadataChangeType}
4957
*/
5058
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
59+
60+
/**
61+
* Drop a value into a new position
62+
* Update the form's value array for the current field to match the dropped position
63+
* Update the values their place property to match the new order
64+
* Send an update to the parent
65+
* @param event
66+
*/
67+
drop(event: CdkDragDrop<any>) {
68+
const dragIndex = event.previousIndex;
69+
const dropIndex = event.currentIndex;
70+
// Move the value within its field
71+
moveItemInArray(this.form.fields[this.mdField], dragIndex, dropIndex);
72+
// Update all the values in this field their place property
73+
this.form.fields[this.mdField].forEach((value: DsoEditMetadataValue, index: number) => {
74+
value.newValue.place = index;
75+
value.confirmChanges();
76+
});
77+
// Update the form statuses
78+
this.form.resetReinstatable();
79+
this.valueSaved.emit();
80+
}
5181
}

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { MetadataMap, MetadataValue } from '../../core/shared/metadata.models';
22
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
3-
import { Operation } from 'fast-json-patch';
3+
import { MoveOperation, Operation } from 'fast-json-patch';
44
import { MetadataPatchReplaceOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model';
55
import { MetadataPatchRemoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model';
66
import { MetadataPatchAddOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model';
7-
import { MetadataPatchOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model';
7+
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
8+
import { MetadataPatchMoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model';
89

910
/* tslint:disable:max-classes-per-file */
1011

@@ -69,7 +70,7 @@ export class DsoEditMetadataValue {
6970
*/
7071
confirmChanges(finishEditing = false) {
7172
if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) {
72-
if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) {
73+
if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language || this.originalValue.place !== this.newValue.place)) {
7374
this.change = DsoEditMetadataChangeType.UPDATE;
7475
} else {
7576
this.change = undefined;
@@ -187,7 +188,7 @@ export class DsoEditMetadataForm {
187188
Object.entries(metadata).forEach(([mdField, values]: [string, MetadataValue[]]) => {
188189
this.originalFieldKeys.push(mdField);
189190
this.fieldKeys.push(mdField);
190-
this.fields[mdField] = values.map((value: MetadataValue) => new DsoEditMetadataValue(value));
191+
this.setValuesForFieldSorted(mdField, values.map((value: MetadataValue) => new DsoEditMetadataValue(value)));
191192
});
192193
}
193194

@@ -208,6 +209,10 @@ export class DsoEditMetadataForm {
208209
setMetadataField(mdField: string): void {
209210
this.newValue.editing = false;
210211
this.addValueToField(this.newValue, mdField);
212+
// Set the place property to match the new value's position within its field
213+
const place = this.fields[mdField].length - 1;
214+
this.fields[mdField][place].originalValue.place = place;
215+
this.fields[mdField][place].newValue.place = place;
211216
this.newValue = undefined;
212217
}
213218

@@ -253,6 +258,7 @@ export class DsoEditMetadataForm {
253258
*/
254259
discard(): void {
255260
this.resetReinstatable();
261+
// Discard changes from each value from each field
256262
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
257263
let removeFromIndex = -1;
258264
values.forEach((value: DsoEditMetadataValue, index: number) => {
@@ -272,25 +278,39 @@ export class DsoEditMetadataForm {
272278
this.fields[field].splice(removeFromIndex, this.fields[field].length - removeFromIndex);
273279
}
274280
});
281+
// Delete new metadata fields
275282
this.fieldKeys.forEach((field: string) => {
276283
if (this.originalFieldKeys.indexOf(field) < 0) {
277284
delete this.fields[field];
278285
}
279286
});
280287
this.fieldKeys = [...this.originalFieldKeys];
288+
// Reset the order of values within their fields to match their place property
289+
this.fieldKeys.forEach((field: string) => {
290+
this.setValuesForFieldSorted(field, this.fields[field]);
291+
});
281292
}
282293

294+
/**
295+
* Undo any previously discarded changes
296+
*/
283297
reinstate(): void {
298+
// Reinstate each value
284299
Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => {
285300
values.forEach((value: DsoEditMetadataValue) => {
286301
value.reinstate();
287302
});
288303
});
304+
// Re-add new values
289305
Object.entries(this.reinstatableNewValues).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
290306
values.forEach((value: DsoEditMetadataValue) => {
291307
this.addValueToField(value, field);
292308
});
293309
});
310+
// Reset the order of values within their fields to match their place property
311+
this.fieldKeys.forEach((field: string) => {
312+
this.setValuesForFieldSorted(field, this.fields[field]);
313+
});
294314
this.reinstatableNewValues = {};
295315
}
296316

@@ -316,35 +336,74 @@ export class DsoEditMetadataForm {
316336
});
317337
}
318338

339+
/**
340+
* Set the values of a metadata field and sort them by their newValue's place property
341+
* @param mdField
342+
* @param values
343+
*/
344+
private setValuesForFieldSorted(mdField: string, values: DsoEditMetadataValue[]) {
345+
this.fields[mdField] = values.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place);
346+
}
347+
319348
/**
320349
* Get the json PATCH operations for the current changes within this form
350+
* For each metadata field, it'll return operations in the following order: replace, remove (from last to first place), add and move
351+
* This order is important, as each operation is executed in succession of the previous one
321352
*/
322-
getOperations(): Operation[] {
353+
getOperations(moveAnalyser: ArrayMoveChangeAnalyzer<number>): Operation[] {
323354
const operations: Operation[] = [];
324355
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
325-
values.forEach((value: DsoEditMetadataValue, place: number) => {
326-
if (value.hasChanges()) {
327-
let operation: MetadataPatchOperation;
328-
if (value.change === DsoEditMetadataChangeType.UPDATE) {
329-
operation = new MetadataPatchReplaceOperation(field, place, {
330-
value: value.newValue.value,
331-
language: value.newValue.language,
332-
});
333-
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
334-
operation = new MetadataPatchRemoveOperation(field, place);
335-
} else if (value.change === DsoEditMetadataChangeType.ADD) {
336-
operation = new MetadataPatchAddOperation(field, {
337-
value: value.newValue.value,
338-
language: value.newValue.language,
339-
});
340-
} else {
341-
console.warn('Illegal metadata change state detected for', value);
342-
}
343-
if (hasValue(operation)) {
344-
operations.push(operation.toOperation());
356+
const replaceOperations: MetadataPatchReplaceOperation[] = [];
357+
const removeOperations: MetadataPatchRemoveOperation[] = [];
358+
const addOperations: MetadataPatchAddOperation[] = [];
359+
values
360+
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place)
361+
.forEach((value: DsoEditMetadataValue) => {
362+
if (value.hasChanges()) {
363+
if (value.change === DsoEditMetadataChangeType.UPDATE) {
364+
// Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below.
365+
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language) {
366+
replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
367+
value: value.newValue.value,
368+
language: value.newValue.language,
369+
}));
370+
}
371+
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
372+
removeOperations.push(new MetadataPatchRemoveOperation(field, value.originalValue.place));
373+
} else if (value.change === DsoEditMetadataChangeType.ADD) {
374+
addOperations.push(new MetadataPatchAddOperation(field, {
375+
value: value.newValue.value,
376+
language: value.newValue.language,
377+
}));
378+
} else {
379+
console.warn('Illegal metadata change state detected for', value);
380+
}
345381
}
346-
}
347-
});
382+
});
383+
384+
operations.push(...replaceOperations
385+
.map((operation: MetadataPatchReplaceOperation) => operation.toOperation()));
386+
operations.push(...removeOperations
387+
// Sort remove operations backwards first, because they get executed in order. This avoids one removal affecting the next.
388+
.sort((a: MetadataPatchRemoveOperation, b: MetadataPatchRemoveOperation) => b.place - a.place)
389+
.map((operation: MetadataPatchRemoveOperation) => operation.toOperation()));
390+
operations.push(...addOperations
391+
.map((operation: MetadataPatchAddOperation) => operation.toOperation()));
392+
});
393+
// 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
394+
// This uses an ArrayMoveChangeAnalyzer
395+
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
396+
// Exclude values marked for removal, because operations are executed in order (remove first, then move)
397+
const valuesWithoutRemoved = values.filter((value: DsoEditMetadataValue) => value.change !== DsoEditMetadataChangeType.REMOVE);
398+
const moveOperations = moveAnalyser
399+
.diff(
400+
valuesWithoutRemoved
401+
.map((value: DsoEditMetadataValue) => value.originalValue.place),
402+
valuesWithoutRemoved
403+
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place)
404+
.map((value: DsoEditMetadataValue) => value.originalValue.place))
405+
.map((operation: MoveOperation) => new MetadataPatchMoveOperation(field, +operation.from.substr(1), +operation.path.substr(1)).toOperation());
406+
operations.push(...moveOperations);
348407
});
349408
return operations;
350409
}

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<div class="d-flex flex-row ds-value-row" *ngVar="mdValue.newValue.isVirtual as isVirtual"
2+
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
23
[ngClass]="{ 'ds-warning': mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
34
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex align-items-center" *ngVar="(mdRepresentation$ | async) as mdRepresentation">
45
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div>
@@ -34,8 +35,7 @@
3435
<i class="fas fa-undo-alt fa-fw"></i>
3536
</button>
3637
</div>
37-
<!-- TODO: Enable drag -->
38-
<button class="btn btn-outline-secondary ds-drag-handle btn-sm"
38+
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" cdkDragHandle
3939
[ngClass]="{'disabled': isOnlyValue || (saving$ | async)}" ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}" [disabled]="isOnlyValue || (saving$ | async)">
4040
<i class="fas fa-grip-vertical fa-fw"></i>
4141
</button>

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@
1010
::ng-deep .tooltip-inner {
1111
min-width: var(--ds-dso-edit-virtual-tooltip-min-width);
1212
}
13+
14+
.cdk-drag-placeholder {
15+
opacity: 0;
16+
}

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export class DsoEditMetadataValueComponent implements OnInit {
7171
*/
7272
@Output() undo: EventEmitter<any> = new EventEmitter<any>();
7373

74+
/**
75+
* Emits true when the user starts dragging a value, false when the user stops dragging
76+
*/
77+
@Output() dragging: EventEmitter<boolean> = new EventEmitter<boolean>();
78+
7479
/**
7580
* The DsoEditMetadataChangeType enumeration for access in the component's template
7681
* @type {DsoEditMetadataChangeType}

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
[form]="form"
4949
[dsoType]="dsoType"
5050
[saving$]="saving$"
51+
[draggingMdField$]="draggingMdField$"
5152
[mdField]="mdField"
5253
(valueSaved)="onValueSaved()">
5354
</ds-dso-edit-metadata-field-values>

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
1919
import { TranslateService } from '@ngx-translate/core';
2020
import { MetadataFieldSelectorComponent } from './metadata-field-selector/metadata-field-selector.component';
2121
import { Observable } from 'rxjs/internal/Observable';
22+
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
2223

2324
@Component({
2425
selector: 'ds-dso-edit-metadata',
@@ -72,6 +73,13 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
7273
*/
7374
saving$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
7475

76+
/**
77+
* Tracks for which metadata-field a drag operation is taking place
78+
* Null when no drag is currently happening for any field
79+
* This is a BehaviorSubject that is passed down to child components, to give them the power to alter the state
80+
*/
81+
draggingMdField$: BehaviorSubject<string> = new BehaviorSubject<string>(null);
82+
7583
/**
7684
* Whether or not the metadata field is currently being validated
7785
*/
@@ -98,7 +106,8 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
98106
constructor(protected route: ActivatedRoute,
99107
protected notificationsService: NotificationsService,
100108
protected translateService: TranslateService,
101-
protected parentInjector: Injector) {
109+
protected parentInjector: Injector,
110+
protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer<number>) {
102111
}
103112

104113
/**
@@ -167,7 +176,7 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
167176
*/
168177
submit(): void {
169178
this.saving$.next(true);
170-
this.updateDataService.patch(this.dso, this.form.getOperations()).pipe(
179+
this.updateDataService.patch(this.dso, this.form.getOperations(this.arrayMoveChangeAnalyser)).pipe(
171180
getFirstCompletedRemoteData()
172181
).subscribe((rd: RemoteData<DSpaceObject>) => {
173182
this.saving$.next(false);

0 commit comments

Comments
 (0)