97075: Edit-metadata redesign messages + JSDocs

This commit is contained in:
Kristof De Langhe
2022-12-01 14:33:23 +01:00
parent 6be343cccf
commit 532a59006f
7 changed files with 295 additions and 28 deletions

View File

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

View 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>

View File

@@ -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();

View File

@@ -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)">

View File

@@ -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 {
this.mdFieldInput.nativeElement.focus(); if (this.autofocus) {
this.mdFieldInput.nativeElement.focus();
}
} }
/**
* Validate the metadata field to check if it exists on the server and return an observable boolean for success/error
* Upon subscribing to the returned observable, the showInvalid flag is updated accordingly to show the feedback under the input
*/
validate(): Observable<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());
} }

View File

@@ -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';

View File

@@ -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",