diff --git a/src/app/access-control/group-registry/group-form/group-form.component.html b/src/app/access-control/group-registry/group-form/group-form.component.html index 0fc5a574b7..d86adc674b 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.html +++ b/src/app/access-control/group-registry/group-form/group-form.component.html @@ -9,7 +9,18 @@ -

{{messagePrefix + '.head.edit' | translate}}

+

+ + {{messagePrefix + '.head.edit' | translate}} + +

{{messagePrefix + '.head' | translate}}

- +
+ +
+
+
{{ mdValue.newValue.language }}
+ +
+
+
+
+
+ + + + +
+
+ +
+
+ diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss new file mode 100644 index 0000000000..4a207ee1a4 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss @@ -0,0 +1,16 @@ +.ds-success { + background-color: var(--bs-success-bg); + border: 1px solid var(--bs-success); +} + +.ds-drag-handle:not(.disabled) { + cursor: grab; +} + +::ng-deep .edit-field>ngb-tooltip-window .tooltip-inner { + min-width: var(--ds-dso-edit-virtual-tooltip-min-width); +} + +.cdk-drag-placeholder { + opacity: 0; +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts new file mode 100644 index 0000000000..10b3016a52 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -0,0 +1,170 @@ +import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { RelationshipDataService } from '../../../core/data/relationship-data.service'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { of } from 'rxjs/internal/observable/of'; +import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models'; +import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; +import { By } from '@angular/platform-browser'; + +const EDIT_BTN = 'edit'; +const CONFIRM_BTN = 'confirm'; +const REMOVE_BTN = 'remove'; +const UNDO_BTN = 'undo'; +const DRAG_BTN = 'drag'; + +describe('DsoEditMetadataValueComponent', () => { + let component: DsoEditMetadataValueComponent; + let fixture: ComponentFixture; + + let relationshipService: RelationshipDataService; + let dsoNameService: DSONameService; + + let editMetadataValue: DsoEditMetadataValue; + let metadataValue: MetadataValue; + + function initServices(): void { + relationshipService = jasmine.createSpyObj('relationshipService', { + resolveMetadataRepresentation: of(new ItemMetadataRepresentation(metadataValue)), + }); + dsoNameService = jasmine.createSpyObj('dsoNameService', { + getName: 'Related Name', + }); + } + + beforeEach(waitForAsync(() => { + metadataValue = Object.assign(new MetadataValue(), { + value: 'Regular Name', + language: 'en', + place: 0, + authority: undefined, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + + initServices(); + + TestBed.configureTestingModule({ + declarations: [DsoEditMetadataValueComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: RelationshipDataService, useValue: relationshipService }, + { provide: DSONameService, useValue: dsoNameService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoEditMetadataValueComponent); + component = fixture.componentInstance; + component.mdValue = editMetadataValue; + component.saving$ = of(false); + fixture.detectChanges(); + }); + + it('should not show a badge', () => { + expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeNull(); + }); + + describe('when no changes have been made', () => { + assertButton(EDIT_BTN, true, false); + assertButton(CONFIRM_BTN, false); + assertButton(REMOVE_BTN, true, false); + assertButton(UNDO_BTN, true, true); + assertButton(DRAG_BTN, true, false); + }); + + describe('when this is the only metadata value within its field', () => { + beforeEach(() => { + component.isOnlyValue = true; + fixture.detectChanges(); + }); + + assertButton(DRAG_BTN, true, true); + }); + + describe('when the value is marked for removal', () => { + beforeEach(() => { + editMetadataValue.change = DsoEditMetadataChangeType.REMOVE; + fixture.detectChanges(); + }); + + assertButton(REMOVE_BTN, true, true); + assertButton(UNDO_BTN, true, false); + }); + + describe('when the value is being edited', () => { + beforeEach(() => { + editMetadataValue.editing = true; + fixture.detectChanges(); + }); + + assertButton(EDIT_BTN, false); + assertButton(CONFIRM_BTN, true, false); + assertButton(UNDO_BTN, true, false); + }); + + describe('when the value is new', () => { + beforeEach(() => { + editMetadataValue.change = DsoEditMetadataChangeType.ADD; + fixture.detectChanges(); + }); + + assertButton(REMOVE_BTN, true, false); + assertButton(UNDO_BTN, true, false); + }); + + describe('when the metadata value is virtual', () => { + beforeEach(() => { + metadataValue = Object.assign(new MetadataValue(), { + value: 'Virtual Name', + language: 'en', + place: 0, + authority: `${VIRTUAL_METADATA_PREFIX}authority-key`, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + component.mdValue = editMetadataValue; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should show a badge', () => { + expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeTruthy(); + }); + + assertButton(EDIT_BTN, true, true); + assertButton(CONFIRM_BTN, false); + assertButton(REMOVE_BTN, true, true); + assertButton(UNDO_BTN, true, true); + assertButton(DRAG_BTN, true, false); + }); + + function assertButton(name: string, exists: boolean, disabled: boolean = false): void { + describe(`${name} button`, () => { + let btn: DebugElement; + + beforeEach(() => { + btn = fixture.debugElement.query(By.css(`#metadata-${name}-btn`)); + }); + + if (exists) { + it('should exist', () => { + expect(btn).toBeTruthy(); + }); + + it(`should${disabled ? ' ' : ' not '}be disabled`, () => { + expect(btn.nativeElement.disabled).toBe(disabled); + }); + } else { + it('should not exist', () => { + expect(btn).toBeNull(); + }); + } + }); + } +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts new file mode 100644 index 0000000000..3fdcd381ab --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -0,0 +1,126 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; +import { Observable } from 'rxjs/internal/Observable'; +import { + MetadataRepresentation, + MetadataRepresentationType +} from '../../../core/shared/metadata-representation/metadata-representation.model'; +import { RelationshipDataService } from '../../../core/data/relationship-data.service'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { map } from 'rxjs/operators'; +import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { EMPTY } from 'rxjs/internal/observable/empty'; + +@Component({ + selector: 'ds-dso-edit-metadata-value', + styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'], + templateUrl: './dso-edit-metadata-value.component.html', +}) +/** + * Component displaying a single editable row for a metadata value + */ +export class DsoEditMetadataValueComponent implements OnInit { + /** + * The parent {@link DSpaceObject} to display a metadata form for + * Also used to determine metadata-representations in case of virtual metadata + */ + @Input() dso: DSpaceObject; + + /** + * Editable metadata value to show + */ + @Input() mdValue: DsoEditMetadataValue; + + /** + * Type of DSO we're displaying values for + * Determines i18n messages + */ + @Input() dsoType: string; + + /** + * Observable to check if the form is being saved or not + * Will disable certain functionality while saving + */ + @Input() saving$: Observable; + + /** + * Is this value the only one within its list? + * Will disable certain functionality like dragging (because dragging within a list of 1 is pointless) + */ + @Input() isOnlyValue = false; + + /** + * Emits when the user clicked edit + */ + @Output() edit: EventEmitter = new EventEmitter(); + + /** + * Emits when the user clicked confirm + */ + @Output() confirm: EventEmitter = new EventEmitter(); + + /** + * Emits when the user clicked remove + */ + @Output() remove: EventEmitter = new EventEmitter(); + + /** + * Emits when the user clicked undo + */ + @Output() undo: EventEmitter = new EventEmitter(); + + /** + * Emits true when the user starts dragging a value, false when the user stops dragging + */ + @Output() dragging: EventEmitter = new EventEmitter(); + + /** + * The DsoEditMetadataChangeType enumeration for access in the component's template + * @type {DsoEditMetadataChangeType} + */ + public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; + + /** + * The item this metadata value represents in case it's virtual (if any, otherwise null) + */ + mdRepresentation$: Observable; + + /** + * The route to the item represented by this virtual metadata value (otherwise null) + */ + mdRepresentationItemRoute$: Observable; + + /** + * The name of the item represented by this virtual metadata value (otherwise null) + */ + mdRepresentationName$: Observable; + + constructor(protected relationshipService: RelationshipDataService, + protected dsoNameService: DSONameService) { + } + + ngOnInit(): void { + this.initVirtualProperties(); + } + + /** + * Initialise potential properties of a virtual metadata value + */ + initVirtualProperties(): void { + this.mdRepresentation$ = this.mdValue.newValue.isVirtual ? + this.relationshipService.resolveMetadataRepresentation(this.mdValue.newValue, this.dso, 'Item') + .pipe( + map((mdRepresentation: MetadataRepresentation) => + mdRepresentation.representationType === MetadataRepresentationType.Item ? mdRepresentation as ItemMetadataRepresentation : null + ) + ) : EMPTY; + this.mdRepresentationItemRoute$ = this.mdRepresentation$.pipe( + map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? getItemPageRoute(mdRepresentation) : null), + ); + this.mdRepresentationName$ = this.mdRepresentation$.pipe( + map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null), + ); + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html new file mode 100644 index 0000000000..24c3dc5cd7 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -0,0 +1,91 @@ + + diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss new file mode 100644 index 0000000000..4e5e9ff1d4 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss @@ -0,0 +1,21 @@ +.lbl-cell { + min-width: var(--ds-dso-edit-field-width); + max-width: var(--ds-dso-edit-field-width); + background-color: var(--bs-gray-100); + font-weight: bold; + padding: 1rem; + border: 1px solid var(--bs-gray-200); + + &.ds-success { + background-color: var(--bs-success-bg); + border: 1px solid var(--bs-success); + } +} + +.ds-field-row { + border: 1px solid var(--bs-gray-400); +} + +.reset-order-button:hover { + cursor: pointer; +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts new file mode 100644 index 0000000000..7067c44fbb --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts @@ -0,0 +1,193 @@ +import { DsoEditMetadataComponent } from './dso-edit-metadata.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DebugElement, Injectable, NO_ERRORS_SCHEMA } from '@angular/core'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { By } from '@angular/platform-browser'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service'; +import { ITEM } from '../../core/shared/item.resource-type'; +import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator'; +import { Operation } from 'fast-json-patch'; +import { RemoteData } from '../../core/data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; + +const ADD_BTN = 'add'; +const REINSTATE_BTN = 'reinstate'; +const SAVE_BTN = 'save'; +const DISCARD_BTN = 'discard'; + +@Injectable() +class TestDataService { + patch(object: Item, operations: Operation[]): Observable> { + return createSuccessfulRemoteDataObject$(object); + } +} + +describe('DsoEditMetadataComponent', () => { + let component: DsoEditMetadataComponent; + let fixture: ComponentFixture; + + let notificationsService: NotificationsService; + + let dso: DSpaceObject; + + beforeEach(waitForAsync(() => { + dso = Object.assign(new Item(), { + type: ITEM, + metadata: { + 'dc.title': [ + Object.assign(new MetadataValue(), { + value: 'Test Title', + language: 'en', + place: 0, + }), + ], + 'dc.subject': [ + Object.assign(new MetadataValue(), { + value: 'Subject One', + language: 'en', + place: 0, + }), + Object.assign(new MetadataValue(), { + value: 'Subject Two', + language: 'en', + place: 1, + }), + Object.assign(new MetadataValue(), { + value: 'Subject Three', + language: 'en', + place: 2, + }), + ], + }, + }); + + notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); + + TestBed.configureTestingModule({ + declarations: [DsoEditMetadataComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + TestDataService, + { provide: DATA_SERVICE_FACTORY, useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService) }, + { provide: NotificationsService, useValue: notificationsService }, + ArrayMoveChangeAnalyzer, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoEditMetadataComponent); + component = fixture.componentInstance; + component.dso = dso; + fixture.detectChanges(); + }); + + describe('when no changes have been made', () => { + assertButton(ADD_BTN, true, false); + assertButton(REINSTATE_BTN, false); + assertButton(SAVE_BTN, true, true); + assertButton(DISCARD_BTN, true, true); + }); + + describe('when the form contains changes', () => { + beforeEach(() => { + component.form.fields['dc.title'][0].newValue.value = 'Updated Title Once'; + component.form.fields['dc.title'][0].confirmChanges(); + component.form.resetReinstatable(); + component.onValueSaved(); + fixture.detectChanges(); + }); + + assertButton(SAVE_BTN, true, false); + assertButton(DISCARD_BTN, true, false); + + describe('and they were discarded', () => { + beforeEach(() => { + component.discard(); + fixture.detectChanges(); + }); + + assertButton(REINSTATE_BTN, true, false); + assertButton(SAVE_BTN, true, true); + assertButton(DISCARD_BTN, false); + + describe('and a new change is made', () => { + beforeEach(() => { + component.form.fields['dc.title'][0].newValue.value = 'Updated Title Twice'; + component.form.fields['dc.title'][0].confirmChanges(); + component.form.resetReinstatable(); + component.onValueSaved(); + fixture.detectChanges(); + }); + + assertButton(REINSTATE_BTN, false); + assertButton(SAVE_BTN, true, false); + assertButton(DISCARD_BTN, true, false); + }); + }); + }); + + describe('when a new value is present', () => { + beforeEach(() => { + component.add(); + fixture.detectChanges(); + }); + + assertButton(ADD_BTN, true, true); + + it('should display a row with a field selector and metadata value', () => { + expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeTruthy(); + }); + + describe('and gets assigned to a metadata field', () => { + beforeEach(() => { + component.form.newValue.newValue.value = 'New Subject'; + component.form.setMetadataField('dc.subject'); + component.form.resetReinstatable(); + component.onValueSaved(); + fixture.detectChanges(); + }); + + assertButton(ADD_BTN, true, false); + + it('should not display the separate row with field selector and metadata value anymore', () => { + expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeNull(); + expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeNull(); + }); + }); + }); + + function assertButton(name: string, exists: boolean, disabled: boolean = false): void { + describe(`${name} button`, () => { + let btn: DebugElement; + + beforeEach(() => { + btn = fixture.debugElement.query(By.css(`#dso-${name}-btn`)); + }); + + if (exists) { + it('should exist', () => { + expect(btn).toBeTruthy(); + }); + + it(`should${disabled ? ' ' : ' not '}be disabled`, () => { + expect(btn.nativeElement.disabled).toBe(disabled); + }); + } else { + it('should not exist', () => { + expect(btn).toBeNull(); + }); + } + }); + } + +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts new file mode 100644 index 0000000000..d67a7ea738 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -0,0 +1,261 @@ +import { Component, Inject, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { AlertType } from '../../shared/alert/aletr-type'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { DsoEditMetadataForm } from './dso-edit-metadata-form'; +import { map } from 'rxjs/operators'; +import { ActivatedRoute, Data } from '@angular/router'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { RemoteData } from '../../core/data/remote-data'; +import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { + getFirstCompletedRemoteData, +} from '../../core/shared/operators'; +import { UpdateDataService } from '../../core/data/update-data.service'; +import { ResourceType } from '../../core/shared/resource-type'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MetadataFieldSelectorComponent } from './metadata-field-selector/metadata-field-selector.component'; +import { Observable } from 'rxjs/internal/Observable'; +import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service'; +import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { HALDataService } from '../../core/data/base/hal-data-service.interface'; + +@Component({ + selector: 'ds-dso-edit-metadata', + styleUrls: ['./dso-edit-metadata.component.scss'], + 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 { + /** + * DSpaceObject to edit metadata for + */ + @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; + + /** + * Resolved update data-service for the given DSpaceObject (depending on its type, e.g. ItemDataService for an Item) + * Used to send the PATCH request + */ + @Input() updateDataService: UpdateDataService; + + /** + * Type of the DSpaceObject in String + * Used to resolve i18n messages + */ + dsoType: string; + + /** + * A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm} + */ + form: DsoEditMetadataForm; + + /** + * The metadata field entered by the user for a new metadata value + */ + newMdField: string; + + // Properties determined by the state of the dynamic form, updated by onValueSaved() + isReinstatable: boolean; + hasChanges: boolean; + isEmpty: boolean; + + /** + * Whether or not the form is currently being submitted + */ + saving$: BehaviorSubject = new BehaviorSubject(false); + + /** + * Tracks for which metadata-field a drag operation is taking place + * Null when no drag is currently happening for any field + * This is a BehaviorSubject that is passed down to child components, to give them the power to alter the state + */ + draggingMdField$: BehaviorSubject = new BehaviorSubject(null); + + /** + * Whether or not the metadata field is currently being validated + */ + loadingFieldValidation$: BehaviorSubject = new BehaviorSubject(false); + + /** + * Combination of saving$ and loadingFieldValidation$ + * Emits true when any of the two emit true + */ + savingOrLoadingFieldValidation$: Observable; + + /** + * The AlertType enumeration for access in the component's template + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * Subscription for updating the current DSpaceObject + * Unsubscribed from in ngOnDestroy() + */ + dsoUpdateSubscription: Subscription; + + constructor(protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + protected parentInjector: Injector, + protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer, + @Inject(DATA_SERVICE_FACTORY) protected getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>) { + } + + /** + * 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 { + if (hasNoValue(this.dso)) { + this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe( + map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)), + map((data: any) => data.dso) + ).subscribe((rd: RemoteData) => { + this.dso = rd.payload; + this.initDataService(); + this.initForm(); + }); + } else { + this.initDataService(); + this.initForm(); + } + this.savingOrLoadingFieldValidation$ = observableCombineLatest([this.saving$, this.loadingFieldValidation$]).pipe( + map(([saving, loading]: [boolean, boolean]) => saving || loading), + ); + } + + /** + * Initialise (resolve) the data-service for the current DSpaceObject + */ + initDataService(): void { + let type: ResourceType; + if (typeof this.dso.type === 'string') { + type = new ResourceType(this.dso.type); + } else { + type = this.dso.type; + } + if (hasNoValue(this.updateDataService)) { + const provider = this.getDataServiceFor(type); + this.updateDataService = Injector.create({ + providers: [], + parent: this.parentInjector + }).get(provider); + } + 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 { + this.form = new DsoEditMetadataForm(this.dso.metadata); + this.onValueSaved(); + } + + /** + * Update the form's state properties + */ + onValueSaved(): void { + this.hasChanges = this.form.hasChanges(); + this.isReinstatable = this.form.isReinstatable(); + 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 { + this.saving$.next(true); + this.updateDataService.patch(this.dso, this.form.getOperations(this.arrayMoveChangeAnalyser)).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd: RemoteData) => { + this.saving$.next(false); + if (rd.hasFailed) { + this.notificationsService.error(this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.error.title`), rd.errorMessage); + } else { + 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.initForm(); + } + }); + } + + /** + * Confirm the newly added value + * @param saved Whether or not the value was manually saved (only then, add the value to its metadata field) + */ + confirmNewValue(saved: boolean): void { + if (saved) { + this.setMetadataField(); + } + } + + /** + * 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(): void { + this.form.resetReinstatable(); + this.loadingFieldValidation$.next(true); + this.metadataFieldSelectorComponent.validate().subscribe((valid: boolean) => { + this.loadingFieldValidation$.next(false); + if (valid) { + this.form.setMetadataField(this.newMdField); + this.onValueSaved(); + } + }); + } + + /** + * Add a new temporary metadata value + */ + add(): void { + this.newMdField = undefined; + this.form.add(); + } + + /** + * Discard all changes within the current form + */ + discard(): void { + this.form.discard(); + this.onValueSaved(); + } + + /** + * Restore any changes previously discarded from the form + */ + reinstate(): void { + this.form.reinstate(); + this.onValueSaved(); + } + + /** + * Unsubscribe from any open subscriptions + */ + ngOnDestroy(): void { + if (hasValue(this.dsoUpdateSubscription)) { + this.dsoUpdateSubscription.unsubscribe(); + } + } + +} diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html new file mode 100644 index 0000000000..4c310bd81b --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html @@ -0,0 +1,19 @@ +
+ +
{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}
+ +
diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.scss similarity index 100% rename from src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss rename to src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.scss diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts new file mode 100644 index 0000000000..e0fde0e8f2 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts @@ -0,0 +1,122 @@ +import { MetadataFieldSelectorComponent } from './metadata-field-selector.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { RegistryService } from '../../../core/registry/registry.service'; +import { MetadataField } from '../../../core/metadata/metadata-field.model'; +import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { By } from '@angular/platform-browser'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; + +describe('MetadataFieldSelectorComponent', () => { + let component: MetadataFieldSelectorComponent; + let fixture: ComponentFixture; + + let registryService: RegistryService; + let notificationsService: NotificationsService; + + let metadataSchema: MetadataSchema; + let metadataFields: MetadataField[]; + + beforeEach(waitForAsync(() => { + metadataSchema = Object.assign(new MetadataSchema(), { + id: 0, + prefix: 'dc', + namespace: 'http://dublincore.org/documents/dcmi-terms/', + }); + metadataFields = [ + Object.assign(new MetadataField(), { + id: 0, + element: 'description', + qualifier: undefined, + schema: createSuccessfulRemoteDataObject$(metadataSchema), + }), + Object.assign(new MetadataField(), { + id: 1, + element: 'description', + qualifier: 'abstract', + schema: createSuccessfulRemoteDataObject$(metadataSchema), + }), + ]; + + registryService = jasmine.createSpyObj('registryService', { + queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); + + TestBed.configureTestingModule({ + declarations: [MetadataFieldSelectorComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: RegistryService, useValue: registryService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataFieldSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('when a query is entered', () => { + const query = 'test query'; + + beforeEach(() => { + component.showInvalid = true; + component.query$.next(query); + }); + + it('should reset showInvalid', () => { + expect(component.showInvalid).toBeFalse(); + }); + + it('should query the registry service for metadata fields and include the schema', () => { + expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, null, true, false, followLink('schema')); + }); + }); + + describe('validate', () => { + it('should return an observable true and show no feedback if the current mdField exists in registry', (done) => { + component.mdField = 'dc.description.abstract'; + component.validate().subscribe((result) => { + expect(result).toBeTrue(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeNull(); + done(); + }); + }); + + it('should return an observable false and show invalid feedback if the current mdField is missing in registry', (done) => { + component.mdField = 'dc.fake.field'; + component.validate().subscribe((result) => { + expect(result).toBeFalse(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeTruthy(); + done(); + }); + }); + + describe('when querying the metadata fields returns an error response', () => { + beforeEach(() => { + (registryService.queryMetadataFields as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Failed')); + }); + + it('should return an observable false and show a notification', (done) => { + component.mdField = 'dc.description.abstract'; + component.validate().subscribe((result) => { + expect(result).toBeFalse(); + expect(notificationsService.error).toHaveBeenCalled(); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts new file mode 100644 index 0000000000..5053a4b83d --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts @@ -0,0 +1,188 @@ +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { switchMap, debounceTime, distinctUntilChanged, map, tap, take } from 'rxjs/operators'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { + getAllSucceededRemoteData, getFirstCompletedRemoteData, + metadataFieldsToString +} from '../../../core/shared/operators'; +import { Observable } from 'rxjs/internal/Observable'; +import { RegistryService } from '../../../core/registry/registry.service'; +import { FormControl } from '@angular/forms'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { hasValue } from '../../../shared/empty.util'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { of } from 'rxjs/internal/observable/of'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'ds-metadata-field-selector', + styleUrls: ['./metadata-field-selector.component.scss'], + templateUrl: './metadata-field-selector.component.html' +}) +/** + * Component displaying a searchable input for metadata-fields + */ +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; + + /** + * If true, the input will be automatically focussed upon when the component is first loaded + */ + @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(); + + /** + * Reference to the metadata-field's input + */ + @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; + + /** + * FormControl for the input + */ + 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 = new BehaviorSubject(null); + + /** + * The amount of time to debounce the query for (in ms) + */ + 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; + + /** + * 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; + + /** + * Subscriptions to unsubscribe from on destroy + */ + subs: Subscription[] = []; + + constructor(protected registryService: RegistryService, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { + } + + /** + * 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 { + this.subs.push( + this.input.valueChanges.pipe( + debounceTime(this.debounceTime), + ).subscribe((valueChange) => { + if (!this.selectedValueLoading) { + this.query$.next(valueChange); + } + this.selectedValueLoading = false; + this.mdField = valueChange; + this.mdFieldChange.emit(this.mdField); + }), + ); + this.mdFieldOptions$ = this.query$.pipe( + distinctUntilChanged(), + switchMap((query: string) => { + this.showInvalid = false; + if (query !== null) { + return this.registryService.queryMetadataFields(query, null, true, false, followLink('schema')).pipe( + getAllSucceededRemoteData(), + metadataFieldsToString(), + ); + } else { + return [[]]; + } + }), + ); + } + + /** + * Focus the input if autofocus is enabled + */ + ngAfterViewInit(): void { + 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 { + return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd) => { + if (rd.hasSucceeded) { + return of(rd).pipe( + metadataFieldsToString(), + take(1), + map((fields: string[]) => fields.indexOf(this.mdField) > -1), + tap((exists: boolean) => this.showInvalid = !exists), + ); + } else { + this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); + return [false]; + } + }), + ); + } + + /** + * Select a metadata field from the dropdown options + * @param mdFieldOption + */ + select(mdFieldOption: string): void { + this.selectedValueLoading = true; + this.input.setValue(mdFieldOption); + } + + /** + * Unsubscribe from any open subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub: Subscription) => hasValue(sub)).forEach((sub: Subscription) => sub.unsubscribe()); + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component.ts new file mode 100644 index 0000000000..ba21907c99 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component.ts @@ -0,0 +1,33 @@ +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { DsoEditMetadataComponent } from './dso-edit-metadata.component'; +import { Component, Input } from '@angular/core'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { UpdateDataService } from '../../core/data/update-data.service'; + +@Component({ + selector: 'ds-themed-dso-edit-metadata', + styleUrls: [], + templateUrl: './../../shared/theme-support/themed.component.html', +}) +export class ThemedDsoEditMetadataComponent extends ThemedComponent { + + @Input() dso: DSpaceObject; + + @Input() updateDataService: UpdateDataService; + + protected inAndOutputNames: (keyof DsoEditMetadataComponent & keyof this)[] = ['dso', 'updateDataService']; + + protected getComponentName(): string { + return 'DsoEditMetadataComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./dso-edit-metadata.component`); + } + + +} diff --git a/src/app/dso-shared/dso-shared.module.ts b/src/app/dso-shared/dso-shared.module.ts new file mode 100644 index 0000000000..7d44d6a920 --- /dev/null +++ b/src/app/dso-shared/dso-shared.module.ts @@ -0,0 +1,36 @@ +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { DsoEditMetadataComponent } from './dso-edit-metadata/dso-edit-metadata.component'; +import { MetadataFieldSelectorComponent } from './dso-edit-metadata/metadata-field-selector/metadata-field-selector.component'; +import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component'; +import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component'; +import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component'; +import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component'; +import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-edit-metadata.component'; + +@NgModule({ + imports: [ + SharedModule, + ], + declarations: [ + DsoEditMetadataComponent, + ThemedDsoEditMetadataComponent, + MetadataFieldSelectorComponent, + DsoEditMetadataFieldValuesComponent, + DsoEditMetadataValueComponent, + DsoEditMetadataHeadersComponent, + DsoEditMetadataValueHeadersComponent, + ], + exports: [ + DsoEditMetadataComponent, + ThemedDsoEditMetadataComponent, + MetadataFieldSelectorComponent, + DsoEditMetadataFieldValuesComponent, + DsoEditMetadataValueComponent, + DsoEditMetadataHeadersComponent, + DsoEditMetadataValueHeadersComponent, + ], +}) +export class DsoSharedModule { + +} diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index 7e20edca6b..6e2ded334b 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -35,6 +35,10 @@ import { VersionDataService } from '../../../../core/data/version-data.service'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { SearchService } from '../../../../core/shared/search/search.service'; import { mockRouteService } from '../../../../item-page/simple/item-types/shared/item.component.spec'; +import { + BrowseDefinitionDataServiceStub +} from '../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -100,7 +104,8 @@ describe('JournalComponent', () => { { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: WorkspaceitemDataService, useValue: {} }, { provide: SearchService, useValue: {} }, - { provide: RouteService, useValue: mockRouteService } + { provide: RouteService, useValue: mockRouteService }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub } ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 88236d381e..97265d7a23 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -64,7 +64,7 @@