mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
97075: Edit-metadata redesign messages + JSDocs
This commit is contained in:
@@ -6,19 +6,50 @@ import { MetadataPatchRemoveOperation } from '../../core/data/object-updates/pat
|
|||||||
import { MetadataPatchAddOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-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 { MetadataPatchOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model';
|
||||||
|
|
||||||
|
/* tslint:disable:max-classes-per-file */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration for the type of change occurring on a metadata value
|
||||||
|
*/
|
||||||
export enum DsoEditMetadataChangeType {
|
export enum DsoEditMetadataChangeType {
|
||||||
UPDATE = 1,
|
UPDATE = 1,
|
||||||
ADD = 2,
|
ADD = 2,
|
||||||
REMOVE = 3
|
REMOVE = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class holding information about a metadata value and its changes within an edit form
|
||||||
|
*/
|
||||||
export class DsoEditMetadataValue {
|
export class DsoEditMetadataValue {
|
||||||
|
/**
|
||||||
|
* The original metadata value (should stay the same!) used to compare changes with
|
||||||
|
*/
|
||||||
originalValue: MetadataValue;
|
originalValue: MetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new value, dynamically changing
|
||||||
|
*/
|
||||||
newValue: MetadataValue;
|
newValue: MetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value that can be used to undo any discarding that took place
|
||||||
|
*/
|
||||||
reinstatableValue: MetadataValue;
|
reinstatableValue: MetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not this value is currently being edited or not
|
||||||
|
*/
|
||||||
editing = false;
|
editing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of change that's taking place on this metadata value
|
||||||
|
* Empty if no changes are made
|
||||||
|
*/
|
||||||
change: DsoEditMetadataChangeType;
|
change: DsoEditMetadataChangeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type or change that can be used to undo any discarding that took place
|
||||||
|
*/
|
||||||
reinstatableChange: DsoEditMetadataChangeType;
|
reinstatableChange: DsoEditMetadataChangeType;
|
||||||
|
|
||||||
constructor(value: MetadataValue, added = false) {
|
constructor(value: MetadataValue, added = false) {
|
||||||
@@ -30,6 +61,12 @@ export class DsoEditMetadataValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
confirmChanges() {
|
||||||
if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) {
|
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)) {
|
||||||
@@ -41,10 +78,19 @@ export class DsoEditMetadataValue {
|
|||||||
this.editing = false;
|
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 {
|
hasChanges(): boolean {
|
||||||
return hasValue(this.change);
|
return hasValue(this.change);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard the current changes and mark the value and change type re-instatable by storing them in their relevant
|
||||||
|
* properties
|
||||||
|
*/
|
||||||
discardAndMarkReinstatable(): void {
|
discardAndMarkReinstatable(): void {
|
||||||
if (this.change === DsoEditMetadataChangeType.UPDATE) {
|
if (this.change === DsoEditMetadataChangeType.UPDATE) {
|
||||||
this.reinstatableValue = this.newValue;
|
this.reinstatableValue = this.newValue;
|
||||||
@@ -53,12 +99,19 @@ export class DsoEditMetadataValue {
|
|||||||
this.discard();
|
this.discard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard the current changes
|
||||||
|
* Call discardAndMarkReinstatable() instead, if the discard should be re-instatable
|
||||||
|
*/
|
||||||
discard(): void {
|
discard(): void {
|
||||||
this.change = undefined;
|
this.change = undefined;
|
||||||
this.newValue = Object.assign(new MetadataValue(), this.originalValue);
|
this.newValue = Object.assign(new MetadataValue(), this.originalValue);
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-instate (undo) the last discard by replacing the value and change type with their reinstate properties (if present)
|
||||||
|
*/
|
||||||
reinstate(): void {
|
reinstate(): void {
|
||||||
if (hasValue(this.reinstatableValue)) {
|
if (hasValue(this.reinstatableValue)) {
|
||||||
this.newValue = this.reinstatableValue;
|
this.newValue = this.reinstatableValue;
|
||||||
@@ -70,20 +123,50 @@ export class DsoEditMetadataValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
isReinstatable(): boolean {
|
||||||
return hasValue(this.reinstatableValue) || hasValue(this.reinstatableChange);
|
return hasValue(this.reinstatableValue) || hasValue(this.reinstatableChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class holding information about the metadata of a DSpaceObject and its changes within an edit form
|
||||||
|
*/
|
||||||
export class DsoEditMetadataForm {
|
export class DsoEditMetadataForm {
|
||||||
|
/**
|
||||||
|
* List of original metadata field keys (before any changes took place)
|
||||||
|
*/
|
||||||
originalFieldKeys: string[];
|
originalFieldKeys: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of current metadata field keys (includes new fields for values added by the user)
|
||||||
|
*/
|
||||||
fieldKeys: string[];
|
fieldKeys: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current state of the form
|
||||||
|
* Key: Metadata field
|
||||||
|
* Value: List of {@link DsoEditMetadataValue}s for the metadata field
|
||||||
|
*/
|
||||||
fields: {
|
fields: {
|
||||||
[mdField: string]: DsoEditMetadataValue[],
|
[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: {
|
reinstatableNewValues: {
|
||||||
[mdField: string]: DsoEditMetadataValue[],
|
[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;
|
newValue: DsoEditMetadataValue;
|
||||||
|
|
||||||
constructor(metadata: MetadataMap) {
|
constructor(metadata: MetadataMap) {
|
||||||
@@ -98,18 +181,32 @@ export class DsoEditMetadataForm {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new temporary value for the user to edit
|
||||||
|
*/
|
||||||
add(): void {
|
add(): void {
|
||||||
if (hasNoValue(this.newValue)) {
|
if (hasNoValue(this.newValue)) {
|
||||||
this.newValue = new DsoEditMetadataValue(new MetadataValue(), true);
|
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) {
|
setMetadataField(mdField: string) {
|
||||||
this.newValue.editing = false;
|
this.newValue.editing = false;
|
||||||
this.addValueToField(this.newValue, mdField);
|
this.addValueToField(this.newValue, mdField);
|
||||||
this.newValue = undefined;
|
this.newValue = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a value to a metadata field within the map
|
||||||
|
* @param value
|
||||||
|
* @param mdField
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private addValueToField(value: DsoEditMetadataValue, mdField: string) {
|
private addValueToField(value: DsoEditMetadataValue, mdField: string) {
|
||||||
if (isEmpty(this.fields[mdField])) {
|
if (isEmpty(this.fields[mdField])) {
|
||||||
this.fieldKeys.push(mdField);
|
this.fieldKeys.push(mdField);
|
||||||
@@ -118,6 +215,11 @@ export class DsoEditMetadataForm {
|
|||||||
this.fields[mdField].push(value);
|
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) {
|
remove(mdField: string, index: number) {
|
||||||
if (isNotEmpty(this.fields[mdField])) {
|
if (isNotEmpty(this.fields[mdField])) {
|
||||||
this.fields[mdField].splice(index, 1);
|
this.fields[mdField].splice(index, 1);
|
||||||
@@ -128,10 +230,17 @@ export class DsoEditMetadataForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if at least one value within the form contains a change
|
||||||
|
*/
|
||||||
hasChanges(): boolean {
|
hasChanges(): boolean {
|
||||||
return Object.values(this.fields).some((values) => values.some((value) => value.hasChanges()));
|
return Object.values(this.fields).some((values) => values.some((value) => value.hasChanges()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard all changes within the form and store their current values within re-instatable properties so they can be
|
||||||
|
* undone afterwards
|
||||||
|
*/
|
||||||
discard(): void {
|
discard(): void {
|
||||||
Object.entries(this.fields).forEach(([field, values]) => {
|
Object.entries(this.fields).forEach(([field, values]) => {
|
||||||
let removeFromIndex = -1;
|
let removeFromIndex = -1;
|
||||||
@@ -174,6 +283,9 @@ export class DsoEditMetadataForm {
|
|||||||
this.reinstatableNewValues = {};
|
this.reinstatableNewValues = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if at least one value contains a re-instatable property, meaning a discard can be reversed
|
||||||
|
*/
|
||||||
isReinstatable(): boolean {
|
isReinstatable(): boolean {
|
||||||
return isNotEmpty(this.reinstatableNewValues) ||
|
return isNotEmpty(this.reinstatableNewValues) ||
|
||||||
Object.values(this.fields)
|
Object.values(this.fields)
|
||||||
@@ -181,6 +293,9 @@ export class DsoEditMetadataForm {
|
|||||||
.some((value) => value.isReinstatable()));
|
.some((value) => value.isReinstatable()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the json PATCH operations for the current changes within this form
|
||||||
|
*/
|
||||||
getOperations(): Operation[] {
|
getOperations(): Operation[] {
|
||||||
const operations: Operation[] = [];
|
const operations: Operation[] = [];
|
||||||
Object.entries(this.fields).forEach(([field, values]) => {
|
Object.entries(this.fields).forEach(([field, values]) => {
|
||||||
@@ -211,3 +326,4 @@ export class DsoEditMetadataForm {
|
|||||||
return operations;
|
return operations;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
@@ -20,12 +20,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-row ds-field-row ds-header-row">
|
<div class="d-flex flex-row ds-field-row ds-header-row">
|
||||||
<div class="lbl-cell">Field</div>
|
<div class="lbl-cell">{{ dsoType + '.edit.metadata.headers.field' | translate }}</div>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="d-flex flex-row">
|
<div class="d-flex flex-row">
|
||||||
<div class="flex-grow-1 ds-flex-cell ds-value-cell"><b class="dont-break-out preserve-line-breaks">Value</b></div>
|
<div class="flex-grow-1 ds-flex-cell ds-value-cell"><b class="dont-break-out preserve-line-breaks">{{ dsoType + '.edit.metadata.headers.value' | translate }}</b></div>
|
||||||
<div class="ds-flex-cell ds-lang-cell"><b>Lang</b></div>
|
<div class="ds-flex-cell ds-lang-cell"><b>{{ dsoType + '.edit.metadata.headers.language' | translate }}</b></div>
|
||||||
<div class="text-center ds-flex-cell ds-edit-cell"><b>Edit</b></div>
|
<div class="text-center ds-flex-cell ds-edit-cell"><b>{{ dsoType + '.edit.metadata.headers.edit' | translate }}</b></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,19 +43,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-center ds-flex-cell ds-edit-cell">
|
<div class="text-center ds-flex-cell ds-edit-cell">
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button class="btn btn-outline-success btn-sm ng-star-inserted" ngbTooltip="Confirm changes"
|
<button class="btn btn-outline-success btn-sm ng-star-inserted" ngbTooltip="{{ dsoType + '.edit.buttons.confirm' | translate }}"
|
||||||
[disabled]="!newMdField || (saving$ | async) || (loadingFieldValidation$ | async)" (click)="setMetadataField()">
|
[disabled]="!newMdField || (saving$ | async) || (loadingFieldValidation$ | async)" (click)="setMetadataField()">
|
||||||
<i class="fas fa-check fa-fw"></i>
|
<i class="fas fa-check fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm" ngbTooltip="Remove"
|
<button class="btn btn-outline-danger btn-sm" ngbTooltip="{{ dsoType + '.edit.buttons.remove' | translate }}"
|
||||||
[disabled]="true">
|
[disabled]="true">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-warning btn-sm" ngbTooltip="Undo changes"
|
<button class="btn btn-outline-warning btn-sm" ngbTooltip="{{ dsoType + '.edit.buttons.undo' | translate }}"
|
||||||
[disabled]="(saving$ | async) || (loadingFieldValidation$ | async)" (click)="form.newValue = undefined">
|
[disabled]="(saving$ | async) || (loadingFieldValidation$ | async)" (click)="form.newValue = undefined">
|
||||||
<i class="fas fa-undo-alt fa-fw"></i>
|
<i class="fas fa-undo-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-secondary ds-drag-handle btn-sm disabled" ngbTooltip="Drag to reorder"
|
<button class="btn btn-outline-secondary ds-drag-handle btn-sm disabled" ngbTooltip="{{ dsoType + '.edit.buttons.drag' | translate }}"
|
||||||
[disabled]="true">
|
[disabled]="true">
|
||||||
<i class="fas fa-grip-vertical fa-fw"></i>
|
<i class="fas fa-grip-vertical fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -82,25 +82,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-center ds-flex-cell ds-edit-cell">
|
<div class="text-center ds-flex-cell ds-edit-cell">
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button class="btn btn-outline-primary btn-sm ng-star-inserted" ngbTooltip="Edit" *ngIf="!mdValue.editing"
|
<button class="btn btn-outline-primary btn-sm ng-star-inserted" ngbTooltip="{{ dsoType + '.edit.buttons.edit' | translate }}" *ngIf="!mdValue.editing"
|
||||||
[disabled]="mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || (saving$ | async)" (click)="mdValue.editing = true">
|
[disabled]="mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || (saving$ | async)" (click)="mdValue.editing = true">
|
||||||
<i class="fas fa-edit fa-fw"></i>
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-success btn-sm ng-star-inserted" ngbTooltip="Confirm changes" *ngIf="mdValue.editing"
|
<button class="btn btn-outline-success btn-sm ng-star-inserted" ngbTooltip="{{ dsoType + '.edit.buttons.confirm' | translate }}" *ngIf="mdValue.editing"
|
||||||
[disabled]="(saving$ | async)" (click)="mdValue.confirmChanges(); onValueSaved()">
|
[disabled]="(saving$ | async)" (click)="mdValue.confirmChanges(); onValueSaved()">
|
||||||
<i class="fas fa-check fa-fw"></i>
|
<i class="fas fa-check fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm" ngbTooltip="Remove"
|
<button class="btn btn-outline-danger btn-sm" ngbTooltip="{{ dsoType + '.edit.buttons.remove' | translate }}"
|
||||||
[disabled]="mdValue.change || mdValue.editing || (saving$ | async)" (click)="mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; onValueSaved()">
|
[disabled]="mdValue.change || mdValue.editing || (saving$ | async)" (click)="mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; onValueSaved()">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-warning btn-sm" ngbTooltip="Undo changes"
|
<button class="btn btn-outline-warning btn-sm" ngbTooltip="{{ dsoType + '.edit.buttons.undo' | translate }}"
|
||||||
[disabled]="(!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); onValueSaved()">
|
[disabled]="(!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); onValueSaved()">
|
||||||
<i class="fas fa-undo-alt fa-fw"></i>
|
<i class="fas fa-undo-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<!-- TODO: Enable drag -->
|
<!-- TODO: Enable drag -->
|
||||||
<button class="btn btn-outline-secondary ds-drag-handle btn-sm"
|
<button class="btn btn-outline-secondary ds-drag-handle btn-sm"
|
||||||
[ngClass]="{'disabled': form.fields[mdField].length === 1 || (saving$ | async)}" ngbTooltip="Drag to reorder" [disabled]="form.fields[mdField].length === 1 || (saving$ | async)">
|
[ngClass]="{'disabled': form.fields[mdField].length === 1 || (saving$ | async)}" ngbTooltip="{{ dsoType + '.edit.buttons.drag' | translate }}" [disabled]="form.fields[mdField].length === 1 || (saving$ | async)">
|
||||||
<i class="fas fa-grip-vertical fa-fw"></i>
|
<i class="fas fa-grip-vertical fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,28 +1,22 @@
|
|||||||
import { Component, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import { Component, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
import { AlertType } from '../../shared/alert/aletr-type';
|
import { AlertType } from '../../shared/alert/aletr-type';
|
||||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
import { DsoEditMetadataChangeType, DsoEditMetadataForm, DsoEditMetadataValue } from './dso-edit-metadata-form';
|
import { DsoEditMetadataChangeType, DsoEditMetadataForm } from './dso-edit-metadata-form';
|
||||||
import { map, switchMap } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { ActivatedRoute, Data } from '@angular/router';
|
import { ActivatedRoute, Data } from '@angular/router';
|
||||||
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
|
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||||
import { Subscription } from 'rxjs/internal/Subscription';
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
||||||
import { RegistryService } from '../../core/registry/registry.service';
|
|
||||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
|
||||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
|
||||||
import {
|
import {
|
||||||
getFirstCompletedRemoteData,
|
getFirstCompletedRemoteData,
|
||||||
getFirstSucceededRemoteData,
|
|
||||||
metadataFieldsToString
|
|
||||||
} from '../../core/shared/operators';
|
} from '../../core/shared/operators';
|
||||||
import { UpdateDataService } from '../../core/data/update-data.service';
|
import { UpdateDataService } from '../../core/data/update-data.service';
|
||||||
import { getDataServiceFor } from '../../core/cache/builders/build-decorators';
|
import { getDataServiceFor } from '../../core/cache/builders/build-decorators';
|
||||||
import { ResourceType } from '../../core/shared/resource-type';
|
import { ResourceType } from '../../core/shared/resource-type';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { DataService } from '../../core/data/data.service';
|
|
||||||
import { MetadataFieldSelectorComponent } from './metadata-field-selector/metadata-field-selector.component';
|
import { MetadataFieldSelectorComponent } from './metadata-field-selector/metadata-field-selector.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -30,19 +24,56 @@ import { MetadataFieldSelectorComponent } from './metadata-field-selector/metada
|
|||||||
styleUrls: ['./dso-edit-metadata.component.scss'],
|
styleUrls: ['./dso-edit-metadata.component.scss'],
|
||||||
templateUrl: './dso-edit-metadata.component.html',
|
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 {
|
export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
||||||
|
/**
|
||||||
|
* DSpaceObject to edit metadata for
|
||||||
|
*/
|
||||||
@Input() dso: DSpaceObject;
|
@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;
|
@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
|
||||||
|
*/
|
||||||
updateDataService: UpdateDataService<DSpaceObject>;
|
updateDataService: UpdateDataService<DSpaceObject>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of the DSpaceObject in String
|
||||||
|
* Used to resolve i18n messages
|
||||||
|
*/
|
||||||
dsoType: string;
|
dsoType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm}
|
||||||
|
*/
|
||||||
form: DsoEditMetadataForm;
|
form: DsoEditMetadataForm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata field entered by the user for a new metadata value
|
||||||
|
*/
|
||||||
newMdField: string;
|
newMdField: string;
|
||||||
|
|
||||||
|
// Properties determined by the state of the dynamic form, updated by onValueSaved()
|
||||||
isReinstatable: boolean;
|
isReinstatable: boolean;
|
||||||
hasChanges: boolean;
|
hasChanges: boolean;
|
||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the form is currently being submitted
|
||||||
|
*/
|
||||||
saving$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
saving$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the metadata field is currently being validated
|
||||||
|
*/
|
||||||
loadingFieldValidation$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
loadingFieldValidation$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +88,10 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription for updating the current DSpaceObject
|
||||||
|
* Unsubscribed from in ngOnDestroy()
|
||||||
|
*/
|
||||||
dsoUpdateSubscription: Subscription;
|
dsoUpdateSubscription: Subscription;
|
||||||
|
|
||||||
constructor(protected route: ActivatedRoute,
|
constructor(protected route: ActivatedRoute,
|
||||||
@@ -65,6 +100,10 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
|||||||
protected parentInjector: Injector) {
|
protected parentInjector: Injector) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
ngOnInit(): void {
|
||||||
if (hasNoValue(this.dso)) {
|
if (hasNoValue(this.dso)) {
|
||||||
this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe(
|
this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe(
|
||||||
@@ -81,6 +120,9 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise (resolve) the data-service for the current DSpaceObject
|
||||||
|
*/
|
||||||
initDataService(): void {
|
initDataService(): void {
|
||||||
let type: ResourceType;
|
let type: ResourceType;
|
||||||
if (typeof this.dso.type === 'string') {
|
if (typeof this.dso.type === 'string') {
|
||||||
@@ -96,17 +138,29 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
|||||||
this.dsoType = type.value;
|
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 {
|
initForm(): void {
|
||||||
this.form = new DsoEditMetadataForm(this.dso.metadata);
|
this.form = new DsoEditMetadataForm(this.dso.metadata);
|
||||||
this.onValueSaved();
|
this.onValueSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the form's state properties
|
||||||
|
*/
|
||||||
onValueSaved(): void {
|
onValueSaved(): void {
|
||||||
this.hasChanges = this.form.hasChanges();
|
this.hasChanges = this.form.hasChanges();
|
||||||
this.isReinstatable = this.form.isReinstatable();
|
this.isReinstatable = this.form.isReinstatable();
|
||||||
this.isEmpty = Object.keys(this.form.fields).length === 0;
|
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 {
|
submit(): void {
|
||||||
this.saving$.next(true);
|
this.saving$.next(true);
|
||||||
this.updateDataService.patch(this.dso, this.form.getOperations()).pipe(
|
this.updateDataService.patch(this.dso, this.form.getOperations()).pipe(
|
||||||
@@ -114,15 +168,23 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
|||||||
).subscribe((rd: RemoteData<DSpaceObject>) => {
|
).subscribe((rd: RemoteData<DSpaceObject>) => {
|
||||||
this.saving$.next(false);
|
this.saving$.next(false);
|
||||||
if (rd.hasFailed) {
|
if (rd.hasFailed) {
|
||||||
this.notificationsService.error('error', rd.errorMessage);
|
this.notificationsService.error(this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.error.title`), rd.errorMessage);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.success('saved', 'saved');
|
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.dso = rd.payload;
|
||||||
this.initForm();
|
this.initForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
setMetadataField() {
|
||||||
this.loadingFieldValidation$.next(true);
|
this.loadingFieldValidation$.next(true);
|
||||||
this.metadataFieldSelectorComponent.validate().subscribe((valid) => {
|
this.metadataFieldSelectorComponent.validate().subscribe((valid) => {
|
||||||
@@ -134,21 +196,33 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new temporary metadata value
|
||||||
|
*/
|
||||||
add(): void {
|
add(): void {
|
||||||
this.newMdField = undefined;
|
this.newMdField = undefined;
|
||||||
this.form.add();
|
this.form.add();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard all changes within the current form
|
||||||
|
*/
|
||||||
discard(): void {
|
discard(): void {
|
||||||
this.form.discard();
|
this.form.discard();
|
||||||
this.onValueSaved();
|
this.onValueSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore any changes previously discarded from the form
|
||||||
|
*/
|
||||||
reinstate(): void {
|
reinstate(): void {
|
||||||
this.form.reinstate();
|
this.form.reinstate();
|
||||||
this.onValueSaved();
|
this.onValueSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from any open subscriptions
|
||||||
|
*/
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
if (hasValue(this.dsoUpdateSubscription)) {
|
if (hasValue(this.dsoUpdateSubscription)) {
|
||||||
this.dsoUpdateSubscription.unsubscribe();
|
this.dsoUpdateSubscription.unsubscribe();
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
(focusin)="query$.next(mdField)"
|
(focusin)="query$.next(mdField)"
|
||||||
(dsClickOutside)="query$.next(null)"
|
(dsClickOutside)="query$.next(null)"
|
||||||
(click)="$event.stopPropagation();" />
|
(click)="$event.stopPropagation();" />
|
||||||
<div class="invalid-feedback show-feedback" *ngIf="showInvalid">Invalid metadata field, please pick an existing one from the suggestions when searching</div>
|
<div class="invalid-feedback show-feedback" *ngIf="showInvalid">{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}</div>
|
||||||
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (mdFieldOptions$ | async)?.length > 0}">
|
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (mdFieldOptions$ | async)?.length > 0}">
|
||||||
<div class="dropdown-list">
|
<div class="dropdown-list">
|
||||||
<div *ngFor="let mdFieldOption of (mdFieldOptions$ | async)">
|
<div *ngFor="let mdFieldOption of (mdFieldOptions$ | async)">
|
||||||
|
@@ -12,14 +12,14 @@ import {
|
|||||||
import { switchMap, debounceTime, distinctUntilChanged, map, tap, take } from 'rxjs/operators';
|
import { switchMap, debounceTime, distinctUntilChanged, map, tap, take } from 'rxjs/operators';
|
||||||
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||||
import {
|
import {
|
||||||
getAllSucceededRemoteData, getFirstCompletedRemoteData, getFirstSucceededRemoteData,
|
getAllSucceededRemoteData, getFirstSucceededRemoteData,
|
||||||
metadataFieldsToString
|
metadataFieldsToString
|
||||||
} from '../../../core/shared/operators';
|
} from '../../../core/shared/operators';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { RegistryService } from '../../../core/registry/registry.service';
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
import { FormControl } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||||
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
import { Subscription } from 'rxjs/internal/Subscription';
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -27,25 +27,83 @@ import { Subscription } from 'rxjs/internal/Subscription';
|
|||||||
styleUrls: ['./metadata-field-selector.component.scss'],
|
styleUrls: ['./metadata-field-selector.component.scss'],
|
||||||
templateUrl: './metadata-field-selector.component.html'
|
templateUrl: './metadata-field-selector.component.html'
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying a searchable input for metadata-fields
|
||||||
|
*/
|
||||||
export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterViewInit {
|
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;
|
@Input() mdField: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the input will be automatically focussed upon when the component is first loaded
|
||||||
|
*/
|
||||||
@Input() autofocus = false;
|
@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<string>();
|
@Output() mdFieldChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to the metadata-field's input
|
||||||
|
*/
|
||||||
@ViewChild('mdFieldInput', { static: true }) mdFieldInput: ElementRef;
|
@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<string[]>;
|
mdFieldOptions$: Observable<string[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormControl for the input
|
||||||
|
*/
|
||||||
public input: FormControl = new FormControl();
|
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<string> = new BehaviorSubject<string>(null);
|
query$: BehaviorSubject<string> = new BehaviorSubject<string>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of time to debounce the query for (in ms)
|
||||||
|
*/
|
||||||
debounceTime = 300;
|
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;
|
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;
|
showInvalid = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriptions to unsubscribe from on destroy
|
||||||
|
*/
|
||||||
subs: Subscription[] = [];
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
constructor(protected registryService: RegistryService) {
|
constructor(protected registryService: RegistryService) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
ngOnInit(): void {
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.input.valueChanges.pipe(
|
this.input.valueChanges.pipe(
|
||||||
@@ -75,10 +133,19 @@ export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterV
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the input if autofocus is enabled
|
||||||
|
*/
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
|
if (this.autofocus) {
|
||||||
this.mdFieldInput.nativeElement.focus();
|
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<boolean> {
|
validate(): Observable<boolean> {
|
||||||
return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
|
return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
|
||||||
getFirstSucceededRemoteData(),
|
getFirstSucceededRemoteData(),
|
||||||
@@ -89,11 +156,18 @@ export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterV
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a metadata field from the dropdown optipons
|
||||||
|
* @param mdFieldOption
|
||||||
|
*/
|
||||||
select(mdFieldOption: string) {
|
select(mdFieldOption: string) {
|
||||||
this.selectedValueLoading = true;
|
this.selectedValueLoading = true;
|
||||||
this.input.setValue(mdFieldOption);
|
this.input.setValue(mdFieldOption);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from any open subscriptions
|
||||||
|
*/
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,6 @@ import { ItemPrivateComponent } from './item-private/item-private.component';
|
|||||||
import { ItemPublicComponent } from './item-public/item-public.component';
|
import { ItemPublicComponent } from './item-public/item-public.component';
|
||||||
import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
||||||
import { ItemStatusComponent } from './item-status/item-status.component';
|
import { ItemStatusComponent } from './item-status/item-status.component';
|
||||||
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
|
||||||
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
||||||
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
|
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
|
||||||
import { ItemMoveComponent } from './item-move/item-move.component';
|
import { ItemMoveComponent } from './item-move/item-move.component';
|
||||||
|
@@ -1791,6 +1791,10 @@
|
|||||||
|
|
||||||
"item.edit.metadata.discard-button": "Discard",
|
"item.edit.metadata.discard-button": "Discard",
|
||||||
|
|
||||||
|
"item.edit.metadata.edit.buttons.confirm": "Confirm",
|
||||||
|
|
||||||
|
"item.edit.metadata.edit.buttons.drag": "Drag to reorder",
|
||||||
|
|
||||||
"item.edit.metadata.edit.buttons.edit": "Edit",
|
"item.edit.metadata.edit.buttons.edit": "Edit",
|
||||||
|
|
||||||
"item.edit.metadata.edit.buttons.remove": "Remove",
|
"item.edit.metadata.edit.buttons.remove": "Remove",
|
||||||
|
Reference in New Issue
Block a user