diff --git a/src/app/collection-page/collection-page.module.ts b/src/app/collection-page/collection-page.module.ts index ff49b983ff..c46aad48a8 100644 --- a/src/app/collection-page/collection-page.module.ts +++ b/src/app/collection-page/collection-page.module.ts @@ -16,6 +16,7 @@ import { StatisticsModule } from '../statistics/statistics.module'; import { CollectionFormModule } from './collection-form/collection-form.module'; import { ThemedCollectionPageComponent } from './themed-collection-page.component'; import { ComcolModule } from '../shared/comcol/comcol.module'; +import { DsoSharedModule } from '../dso-shared/dso-shared.module'; @NgModule({ imports: [ @@ -26,6 +27,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module'; EditItemPageModule, CollectionFormModule, ComcolModule, + DsoSharedModule, ], declarations: [ CollectionPageComponent, diff --git a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html index 0403a7ecad..4d630659e8 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html @@ -3,7 +3,7 @@

{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}

- +
diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts index 5f5388d935..025791d6dc 100644 --- a/src/app/core/data/array-move-change-analyzer.service.spec.ts +++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts @@ -41,21 +41,28 @@ describe('ArrayMoveChangeAnalyzer', () => { ], new MoveTest(0, 3)); testMove([ + { op: 'move', from: '/2', path: '/3' }, { op: 'move', from: '/0', path: '/3' }, - { op: 'move', from: '/2', path: '/1' } ], new MoveTest(0, 3), new MoveTest(1, 2)); testMove([ + { op: 'move', from: '/3', path: '/4' }, { op: 'move', from: '/0', path: '/1' }, - { op: 'move', from: '/3', path: '/4' } ], new MoveTest(0, 1), new MoveTest(3, 4)); testMove([], new MoveTest(0, 4), new MoveTest(4, 0)); testMove([ + { op: 'move', from: '/2', path: '/3' }, { op: 'move', from: '/0', path: '/3' }, - { op: 'move', from: '/2', path: '/1' } ], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4)); + + testMove([ + { op: 'move', from: '/3', path: '/4' }, + { op: 'move', from: '/2', path: '/4' }, + { op: 'move', from: '/1', path: '/3' }, + { op: 'move', from: '/0', path: '/3' }, + ], new MoveTest(4, 1), new MoveTest(4, 2), new MoveTest(0, 3)); }); describe('when some values are undefined (index 2 and 3)', () => { diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts index f6e07608f6..36744e9f96 100644 --- a/src/app/core/data/array-move-change-analyzer.service.ts +++ b/src/app/core/data/array-move-change-analyzer.service.ts @@ -16,22 +16,31 @@ export class ArrayMoveChangeAnalyzer { * @param array2 The custom array to compare with the original */ diff(array1: T[], array2: T[]): MoveOperation[] { - const result = []; - const moved = [...array1]; - array1.forEach((value: T, index: number) => { - if (hasValue(value)) { - const otherIndex = array2.indexOf(value); - const movedIndex = moved.indexOf(value); - if (index !== otherIndex && movedIndex !== otherIndex) { - moveItemInArray(moved, movedIndex, otherIndex); - result.push(Object.assign({ - op: 'move', - from: '/' + movedIndex, - path: '/' + otherIndex - }) as MoveOperation); - } + return this.getMoves(array1, array2).map((move) => Object.assign({ + op: 'move', + from: '/' + move[0], + path: '/' + move[1], + }) as MoveOperation); + } + + /** + * Determine a set of moves required to transform array1 into array2 + * The moves are returned as an array of pairs of numbers where the first number is the original index and the second + * is the new index + * It is assumed the operations are executed in the order they're returned (and not simultaneously) + * @param array1 + * @param array2 + */ + private getMoves(array1: any[], array2: any[]): number[][] { + const moved = [...array2]; + + return array1.reduce((moves, item, index) => { + if (hasValue(item) && item !== moved[index]) { + const last = moved.lastIndexOf(item); + moveItemInArray(moved, last, index); + moves.unshift([index, last]); } - }); - return result; + return moves; + }, []); } } diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts new file mode 100644 index 0000000000..962d53dfee --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts @@ -0,0 +1,33 @@ +import { MetadataPatchOperation } from './metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for a metadata patch move Operation + */ +export class MetadataPatchMoveOperation extends MetadataPatchOperation { + static operationType = 'move'; + + /** + * The original place of the metadata value to move + */ + from: number; + + /** + * The new place to move the metadata value to + */ + to: number; + + constructor(field: string, from: number, to: number) { + super(MetadataPatchMoveOperation.operationType, field); + this.from = from; + this.to = to; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + toOperation(): Operation { + return { op: this.op as any, from: `/metadata/${this.field}/${this.from}`, path: `/metadata/${this.field}/${this.to}` }; + } +} diff --git a/src/app/core/data/relationship-data.service.spec.ts b/src/app/core/data/relationship-data.service.spec.ts index 39aa7cbd58..4432d5213a 100644 --- a/src/app/core/data/relationship-data.service.spec.ts +++ b/src/app/core/data/relationship-data.service.spec.ts @@ -10,13 +10,19 @@ import { DeleteRequest } from './request.models'; import { RelationshipDataService } from './relationship-data.service'; import { RequestService } from './request.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { RequestEntry } from './request-entry.model'; import { FindListOptions } from './find-list-options.model'; import { testSearchDataImplementation } from './base/search-data.spec'; +import { MetadataValue } from '../shared/metadata.models'; +import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model'; describe('RelationshipDataService', () => { let service: RelationshipDataService; @@ -233,4 +239,152 @@ describe('RelationshipDataService', () => { }); }); }); + + describe('resolveMetadataRepresentation', () => { + const parentItem: Item = Object.assign(new Item(), { + id: 'parent-item', + metadata: { + 'dc.contributor.author': [ + Object.assign(new MetadataValue(), { + language: null, + value: 'Related Author with authority', + authority: 'virtual::related-author', + place: 2 + }), + Object.assign(new MetadataValue(), { + language: null, + value: 'Author without authority', + place: 1 + }), + ], + 'dc.creator': [ + Object.assign(new MetadataValue(), { + language: null, + value: 'Related Creator with authority', + authority: 'virtual::related-creator', + place: 3, + }), + Object.assign(new MetadataValue(), { + language: null, + value: 'Related Creator with authority - unauthorized', + authority: 'virtual::related-creator-unauthorized', + place: 4, + }), + ], + 'dc.title': [ + Object.assign(new MetadataValue(), { + language: null, + value: 'Parent Item' + }), + ] + } + }); + const relatedAuthor: Item = Object.assign(new Item(), { + id: 'related-author', + metadata: { + 'dc.title': [ + Object.assign(new MetadataValue(), { + language: null, + value: 'Related Author' + }), + ] + } + }); + const relatedCreator: Item = Object.assign(new Item(), { + id: 'related-creator', + metadata: { + 'dc.title': [ + Object.assign(new MetadataValue(), { + language: null, + value: 'Related Creator' + }), + ], + 'dspace.entity.type': 'Person', + } + }); + const authorRelation: Relationship = Object.assign(new Relationship(), { + leftItem: createSuccessfulRemoteDataObject$(parentItem), + rightItem: createSuccessfulRemoteDataObject$(relatedAuthor) + }); + const creatorRelation: Relationship = Object.assign(new Relationship(), { + leftItem: createSuccessfulRemoteDataObject$(parentItem), + rightItem: createSuccessfulRemoteDataObject$(relatedCreator), + }); + const creatorRelationUnauthorized: Relationship = Object.assign(new Relationship(), { + leftItem: createSuccessfulRemoteDataObject$(parentItem), + rightItem: createFailedRemoteDataObject$('Unauthorized', 401), + }); + + let metadatum: MetadataValue; + + beforeEach(() => { + service.findById = (id: string) => { + if (id === 'related-author') { + return createSuccessfulRemoteDataObject$(authorRelation); + } + if (id === 'related-creator') { + return createSuccessfulRemoteDataObject$(creatorRelation); + } + if (id === 'related-creator-unauthorized') { + return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized); + } + }; + }); + + describe('when the metadata isn\'t virtual', () => { + beforeEach(() => { + metadatum = parentItem.metadata['dc.contributor.author'][1]; + }); + + it('should return a plain text MetadatumRepresentation', (done) => { + service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => { + expect(result.representationType).toEqual(MetadataRepresentationType.PlainText); + done(); + }); + }); + }); + + describe('when the metadata is a virtual author', () => { + beforeEach(() => { + metadatum = parentItem.metadata['dc.contributor.author'][0]; + }); + + it('should return a ItemMetadataRepresentation with the correct value', (done) => { + service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => { + expect(result.representationType).toEqual(MetadataRepresentationType.Item); + expect(result.getValue()).toEqual(metadatum.value); + expect((result as any).id).toEqual(relatedAuthor.id); + done(); + }); + }); + }); + + describe('when the metadata is a virtual creator', () => { + beforeEach(() => { + metadatum = parentItem.metadata['dc.creator'][0]; + }); + + it('should return a ItemMetadataRepresentation with the correct value', (done) => { + service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => { + expect(result.representationType).toEqual(MetadataRepresentationType.Item); + expect(result.getValue()).toEqual(metadatum.value); + expect((result as any).id).toEqual(relatedCreator.id); + done(); + }); + }); + }); + + describe('when the metadata refers to a relationship leading to an error response', () => { + beforeEach(() => { + metadatum = parentItem.metadata['dc.creator'][1]; + }); + + it('should return an authority controlled MetadatumRepresentation', (done) => { + service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => { + expect(result.representationType).toEqual(MetadataRepresentationType.AuthorityControlled); + done(); + }); + }); + }); + }); }); diff --git a/src/app/core/data/relationship-data.service.ts b/src/app/core/data/relationship-data.service.ts index 85adeff754..46a51a2d01 100644 --- a/src/app/core/data/relationship-data.service.ts +++ b/src/app/core/data/relationship-data.service.ts @@ -1,7 +1,7 @@ import { HttpHeaders } from '@angular/common/http'; import { Inject, Injectable } from '@angular/core'; import { MemoizedSelector, select, Store } from '@ngrx/store'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; import { compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR, @@ -46,6 +46,11 @@ import { PutData, PutDataImpl } from './base/put-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; import { dataService } from './base/data-service.decorator'; import { itemLinksToFollow } from '../../shared/utils/relation-query.utils'; +import { MetadataValue } from '../shared/metadata.models'; +import { MetadataRepresentation } from '../shared/metadata-representation/metadata-representation.model'; +import { MetadatumRepresentation } from '../shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ItemMetadataRepresentation } from '../shared/metadata-representation/item/item-metadata-representation.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; @@ -550,4 +555,40 @@ export class RelationshipDataService extends IdentifiableDataService[]): Observable>> { return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Resolve a {@link MetadataValue} into a {@link MetadataRepresentation} of the correct type + * @param metadatum {@link MetadataValue} to resolve + * @param parentItem Parent dspace object the metadata value belongs to + * @param itemType The type of item this metadata value represents (will only be used when no related item can be found, as a fallback) + */ + resolveMetadataRepresentation(metadatum: MetadataValue, parentItem: DSpaceObject, itemType: string): Observable { + if (metadatum.isVirtual) { + return this.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe( + getFirstSucceededRemoteData(), + switchMap((relRD: RemoteData) => + observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe( + filter(([leftItem, rightItem]) => leftItem.hasCompleted && rightItem.hasCompleted), + map(([leftItem, rightItem]) => { + if (!leftItem.hasSucceeded || !rightItem.hasSucceeded) { + return null; + } else if (rightItem.hasSucceeded && leftItem.payload.id === parentItem.id) { + return rightItem.payload; + } else if (rightItem.payload.id === parentItem.id) { + return leftItem.payload; + } + }), + map((item: Item) => { + if (hasValue(item)) { + return Object.assign(new ItemMetadataRepresentation(metadatum), item); + } else { + return Object.assign(new MetadatumRepresentation(itemType), metadatum); + } + }) + ) + )); + } else { + return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum)); + } + } } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 32610c82fd..94ca25235b 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -226,7 +226,7 @@ export const metadataFieldsToString = () => map((schema: MetadataSchema) => ({ field, schema })) ); }); - return observableCombineLatest(fieldSchemaArray); + return isNotEmpty(fieldSchemaArray) ? observableCombineLatest(fieldSchemaArray) : [[]]; }), map((fieldSchemaArray: { field: MetadataField, schema: MetadataSchema }[]): string[] => { return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString()); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html new file mode 100644 index 0000000000..9f74216d54 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html @@ -0,0 +1,15 @@ +
+ + + +
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.scss new file mode 100644 index 0000000000..6b52c4f4b3 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.scss @@ -0,0 +1,7 @@ +.ds-drop-list { + background-color: var(--bs-gray-500); + + &.disabled { + opacity: 0.3; + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts new file mode 100644 index 0000000000..3ca0dba3b6 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts @@ -0,0 +1,135 @@ +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 { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata-field-values.component'; +import { DsoEditMetadataForm } from '../dso-edit-metadata-form'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { of } from 'rxjs/internal/observable/of'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { By } from '@angular/platform-browser'; + +describe('DsoEditMetadataFieldValuesComponent', () => { + let component: DsoEditMetadataFieldValuesComponent; + let fixture: ComponentFixture; + + let form: DsoEditMetadataForm; + let dso: DSpaceObject; + let mdField: string; + let draggingMdField$: BehaviorSubject; + + beforeEach(waitForAsync(() => { + dso = Object.assign(new DSpaceObject(), { + 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, + }), + ], + }, + }); + form = new DsoEditMetadataForm(dso.metadata); + mdField = 'dc.subject'; + draggingMdField$ = new BehaviorSubject(null); + + TestBed.configureTestingModule({ + declarations: [DsoEditMetadataFieldValuesComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoEditMetadataFieldValuesComponent); + component = fixture.componentInstance; + component.dso = dso; + component.form = form; + component.mdField = mdField; + component.saving$ = of(false); + component.draggingMdField$ = draggingMdField$; + fixture.detectChanges(); + }); + + describe('when draggingMdField$ emits a value equal to mdField', () => { + beforeEach(() => { + draggingMdField$.next(mdField); + fixture.detectChanges(); + }); + + it('should not disable the list', () => { + expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull(); + }); + }); + + describe('when draggingMdField$ emits a value different to mdField', () => { + beforeEach(() => { + draggingMdField$.next(`${mdField}.fake`); + fixture.detectChanges(); + }); + + it('should disable the list', () => { + expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeTruthy(); + }); + }); + + describe('when draggingMdField$ emits null', () => { + beforeEach(() => { + draggingMdField$.next(null); + fixture.detectChanges(); + }); + + it('should not disable the list', () => { + expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull(); + }); + }); + + describe('dropping a value on a different index', () => { + beforeEach(() => { + component.drop(Object.assign({ + previousIndex: 0, + currentIndex: 2, + })); + }); + + it('should physically move the relevant metadata value within the form', () => { + expect(form.fields[mdField][0].newValue.value).toEqual('Subject Two'); + expect(form.fields[mdField][1].newValue.value).toEqual('Subject Three'); + expect(form.fields[mdField][2].newValue.value).toEqual('Subject One'); + }); + + it('should update the metadata values their new place to match the new physical order', () => { + expect(form.fields[mdField][0].newValue.place).toEqual(0); + expect(form.fields[mdField][1].newValue.place).toEqual(1); + expect(form.fields[mdField][2].newValue.place).toEqual(2); + }); + + it('should maintain the metadata values their original place in their original value so it can be used later to determine the patch operations', () => { + expect(form.fields[mdField][0].originalValue.place).toEqual(1); + expect(form.fields[mdField][1].originalValue.place).toEqual(2); + expect(form.fields[mdField][2].originalValue.place).toEqual(0); + }); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts new file mode 100644 index 0000000000..2702e0ba1f --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts @@ -0,0 +1,81 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { DsoEditMetadataChangeType, DsoEditMetadataForm, DsoEditMetadataValue } from '../dso-edit-metadata-form'; +import { Observable } from 'rxjs/internal/Observable'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; + +@Component({ + selector: 'ds-dso-edit-metadata-field-values', + styleUrls: ['./dso-edit-metadata-field-values.component.scss'], + templateUrl: './dso-edit-metadata-field-values.component.html', +}) +/** + * Component displaying table rows for each value for a certain metadata field within a form + */ +export class DsoEditMetadataFieldValuesComponent { + /** + * The parent {@link DSpaceObject} to display a metadata form for + * Also used to determine metadata-representations in case of virtual metadata + */ + @Input() dso: DSpaceObject; + /** + * A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm} + */ + @Input() form: DsoEditMetadataForm; + + /** + * Metadata field to display values for + */ + @Input() mdField: string; + + /** + * 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 + */ + @Input() saving$: Observable; + + /** + * Tracks for which metadata-field a drag operation is taking place + * Null when no drag is currently happening for any field + */ + @Input() draggingMdField$: BehaviorSubject; + + /** + * Emit when the value has been saved within the form + */ + @Output() valueSaved: EventEmitter = new EventEmitter(); + + /** + * The DsoEditMetadataChangeType enumeration for access in the component's template + * @type {DsoEditMetadataChangeType} + */ + public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; + + /** + * Drop a value into a new position + * Update the form's value array for the current field to match the dropped position + * Update the values their place property to match the new order + * Send an update to the parent + * @param event + */ + drop(event: CdkDragDrop) { + const dragIndex = event.previousIndex; + const dropIndex = event.currentIndex; + // Move the value within its field + moveItemInArray(this.form.fields[this.mdField], dragIndex, dropIndex); + // Update all the values in this field their place property + this.form.fields[this.mdField].forEach((value: DsoEditMetadataValue, index: number) => { + value.newValue.place = index; + value.confirmChanges(); + }); + // Update the form statuses + this.form.resetReinstatable(); + this.valueSaved.emit(); + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts new file mode 100644 index 0000000000..5f8680088a --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts @@ -0,0 +1,275 @@ +import { DsoEditMetadataChangeType, DsoEditMetadataForm } from './dso-edit-metadata-form'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { MetadataValue } from '../../core/shared/metadata.models'; + +describe('DsoEditMetadataForm', () => { + let form: DsoEditMetadataForm; + let dso: DSpaceObject; + + beforeEach(() => { + dso = Object.assign(new DSpaceObject(), { + 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, + }), + ], + }, + }); + form = new DsoEditMetadataForm(dso.metadata); + }); + + + describe('adding a new value', () => { + beforeEach(() => { + form.add(); + }); + + it('should add an empty value to \"newValue\" with no place yet and editing set to true', () => { + expect(form.newValue).toBeDefined(); + expect(form.newValue.originalValue.place).toBeUndefined(); + expect(form.newValue.newValue.place).toBeUndefined(); + expect(form.newValue.editing).toBeTrue(); + }); + + it('should not mark the form as changed yet', () => { + expect(form.hasChanges()).toEqual(false); + }); + + describe('and assigning a value and metadata field to it', () => { + let mdField: string; + let value: string; + let expectedPlace: number; + + beforeEach(() => { + mdField = 'dc.subject'; + value = 'Subject Four'; + form.newValue.newValue.value = value; + form.setMetadataField(mdField); + expectedPlace = form.fields[mdField].length - 1; + }); + + it('should add the new value to the values of the relevant field', () => { + expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value); + }); + + it('should set its editing flag to false', () => { + expect(form.fields[mdField][expectedPlace].editing).toBeFalse(); + }); + + it('should set both its original and new place to match its position in the value array', () => { + expect(form.fields[mdField][expectedPlace].newValue.place).toEqual(expectedPlace); + expect(form.fields[mdField][expectedPlace].originalValue.place).toEqual(expectedPlace); + }); + + it('should clear \"newValue\"', () => { + expect(form.newValue).toBeUndefined(); + }); + + it('should mark the form as changed', () => { + expect(form.hasChanges()).toEqual(true); + }); + + describe('discard', () => { + beforeEach(() => { + form.discard(); + }); + + it('should remove the new value', () => { + expect(form.fields[mdField][expectedPlace]).toBeUndefined(); + }); + + it('should mark the form as unchanged again', () => { + expect(form.hasChanges()).toEqual(false); + }); + + describe('reinstate', () => { + beforeEach(() => { + form.reinstate(); + }); + + it('should re-add the new value', () => { + expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value); + }); + + it('should mark the form as changed once again', () => { + expect(form.hasChanges()).toEqual(true); + }); + }); + }); + }); + }); + + describe('removing a value entirely (not just marking deleted)', () => { + it('should remove the value on the correct index', () => { + form.remove('dc.subject', 1); + expect(form.fields['dc.subject'].length).toEqual(2); + expect(form.fields['dc.subject'][0].newValue.value).toEqual('Subject One'); + expect(form.fields['dc.subject'][1].newValue.value).toEqual('Subject Three'); + }); + }); + + describe('moving a value', () => { + beforeEach(() => { + form.fields['dc.subject'][0].newValue.place = form.fields['dc.subject'][1].originalValue.place; + form.fields['dc.subject'][1].newValue.place = form.fields['dc.subject'][0].originalValue.place; + form.fields['dc.subject'][0].confirmChanges(); + form.fields['dc.subject'][1].confirmChanges(); + }); + + it('should mark the value as changed', () => { + expect(form.fields['dc.subject'][0].hasChanges()).toEqual(true); + expect(form.fields['dc.subject'][1].hasChanges()).toEqual(true); + }); + + it('should mark the form as changed', () => { + expect(form.hasChanges()).toEqual(true); + }); + + describe('discard', () => { + beforeEach(() => { + form.discard(); + }); + + it('should reset the moved values their places to their original values', () => { + expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place); + expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place); + }); + + it('should mark the form as unchanged again', () => { + expect(form.hasChanges()).toEqual(false); + }); + + describe('reinstate', () => { + beforeEach(() => { + form.reinstate(); + }); + + it('should move the values to their new places again', () => { + expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place); + expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place); + }); + + it('should mark the form as changed once again', () => { + expect(form.hasChanges()).toEqual(true); + }); + }); + }); + }); + + describe('marking a value deleted', () => { + beforeEach(() => { + form.fields['dc.title'][0].change = DsoEditMetadataChangeType.REMOVE; + }); + + it('should mark the value as changed', () => { + expect(form.fields['dc.title'][0].hasChanges()).toEqual(true); + }); + + it('should mark the form as changed', () => { + expect(form.hasChanges()).toEqual(true); + }); + + describe('discard', () => { + beforeEach(() => { + form.discard(); + }); + + it('should remove the deleted mark from the value', () => { + expect(form.fields['dc.title'][0].change).toBeUndefined(); + }); + + it('should mark the form as unchanged again', () => { + expect(form.hasChanges()).toEqual(false); + }); + + describe('reinstate', () => { + beforeEach(() => { + form.reinstate(); + }); + + it('should re-mark the value as deleted', () => { + expect(form.fields['dc.title'][0].change).toEqual(DsoEditMetadataChangeType.REMOVE); + }); + + it('should mark the form as changed once again', () => { + expect(form.hasChanges()).toEqual(true); + }); + }); + }); + }); + + describe('editing a value', () => { + const value = 'New title'; + + beforeEach(() => { + form.fields['dc.title'][0].editing = true; + form.fields['dc.title'][0].newValue.value = value; + }); + + it('should not mark the form as changed yet', () => { + expect(form.hasChanges()).toEqual(false); + }); + + describe('and confirming the changes', () => { + beforeEach(() => { + form.fields['dc.title'][0].confirmChanges(true); + }); + + it('should mark the value as changed', () => { + expect(form.fields['dc.title'][0].hasChanges()).toEqual(true); + }); + + it('should mark the form as changed', () => { + expect(form.hasChanges()).toEqual(true); + }); + + describe('discard', () => { + beforeEach(() => { + form.discard(); + }); + + it('should reset the changed value to its original value', () => { + expect(form.fields['dc.title'][0].newValue.value).toEqual(form.fields['dc.title'][0].originalValue.value); + }); + + it('should mark the form as unchanged again', () => { + expect(form.hasChanges()).toEqual(false); + }); + + describe('reinstate', () => { + beforeEach(() => { + form.reinstate(); + }); + + it('should put the changed value back in place', () => { + expect(form.fields['dc.title'][0].newValue.value).toEqual(value); + }); + + it('should mark the form as changed once again', () => { + expect(form.hasChanges()).toEqual(true); + }); + }); + }); + }); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts new file mode 100644 index 0000000000..8206ec9135 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts @@ -0,0 +1,453 @@ +/* eslint-disable max-classes-per-file */ +import { MetadataMap, MetadataValue } from '../../core/shared/metadata.models'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { MoveOperation, Operation } from 'fast-json-patch'; +import { MetadataPatchReplaceOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model'; +import { MetadataPatchRemoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model'; +import { MetadataPatchAddOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model'; +import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service'; +import { MetadataPatchMoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model'; + +/** + * Enumeration for the type of change occurring on a metadata value + */ +export enum DsoEditMetadataChangeType { + UPDATE = 1, + ADD = 2, + REMOVE = 3 +} + +/** + * Class holding information about a metadata value and its changes within an edit form + */ +export class DsoEditMetadataValue { + /** + * The original metadata value (should stay the same!) used to compare changes with + */ + originalValue: MetadataValue; + + /** + * The new value, dynamically changing + */ + newValue: MetadataValue; + + /** + * A value that can be used to undo any discarding that took place + */ + reinstatableValue: MetadataValue; + + /** + * Whether or not this value is currently being edited or not + */ + editing = false; + + /** + * The type of change that's taking place on this metadata value + * Empty if no changes are made + */ + change: DsoEditMetadataChangeType; + + /** + * A flag to keep track if the value has been reordered (place has changed) + */ + reordered = false; + + /** + * A type or change that can be used to undo any discarding that took place + */ + reinstatableChange: DsoEditMetadataChangeType; + + constructor(value: MetadataValue, added = false) { + this.originalValue = value; + this.newValue = Object.assign(new MetadataValue(), value); + if (added) { + this.change = DsoEditMetadataChangeType.ADD; + this.editing = true; + } + } + + /** + * 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(finishEditing = false) { + this.reordered = this.originalValue.place !== this.newValue.place; + if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) { + if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) { + this.change = DsoEditMetadataChangeType.UPDATE; + } else { + this.change = undefined; + } + } + if (finishEditing) { + 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 { + return hasValue(this.change) || this.reordered; + } + + /** + * Discard the current changes and mark the value and change type re-instatable by storing them in their relevant + * properties + */ + discardAndMarkReinstatable(): void { + if (this.change === DsoEditMetadataChangeType.UPDATE || this.reordered) { + this.reinstatableValue = this.newValue; + } + this.reinstatableChange = this.change; + this.discard(false); + } + + /** + * Discard the current changes + * Call discardAndMarkReinstatable() instead, if the discard should be re-instatable + */ + discard(keepPlace = true): void { + this.change = undefined; + const place = this.newValue.place; + this.newValue = Object.assign(new MetadataValue(), this.originalValue); + if (keepPlace) { + this.newValue.place = place; + } + this.confirmChanges(true); + } + + /** + * Re-instate (undo) the last discard by replacing the value and change type with their reinstate properties (if present) + */ + reinstate(): void { + if (hasValue(this.reinstatableValue)) { + this.newValue = this.reinstatableValue; + this.reinstatableValue = undefined; + } + if (hasValue(this.reinstatableChange)) { + this.change = this.reinstatableChange; + this.reinstatableChange = undefined; + } + this.confirmChanges(); + } + + /** + * 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 { + return hasValue(this.reinstatableValue) || hasValue(this.reinstatableChange); + } + + /** + * Reset the state of the re-instatable properties + */ + resetReinstatable() { + this.reinstatableValue = undefined; + this.reinstatableChange = undefined; + } +} + +/** + * Class holding information about the metadata of a DSpaceObject and its changes within an edit form + */ +export class DsoEditMetadataForm { + /** + * List of original metadata field keys (before any changes took place) + */ + originalFieldKeys: string[]; + + /** + * List of current metadata field keys (includes new fields for values added by the user) + */ + fieldKeys: string[]; + + /** + * Current state of the form + * Key: Metadata field + * Value: List of {@link DsoEditMetadataValue}s for the metadata field + */ + fields: { + [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: { + [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; + + constructor(metadata: MetadataMap) { + this.originalFieldKeys = []; + this.fieldKeys = []; + this.fields = {}; + this.reinstatableNewValues = {}; + Object.entries(metadata).forEach(([mdField, values]: [string, MetadataValue[]]) => { + this.originalFieldKeys.push(mdField); + this.fieldKeys.push(mdField); + this.setValuesForFieldSorted(mdField, values.map((value: MetadataValue) => new DsoEditMetadataValue(value))); + }); + this.sortFieldKeys(); + } + + /** + * Add a new temporary value for the user to edit + */ + add(): void { + if (hasNoValue(this.newValue)) { + 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): void { + this.newValue.editing = false; + this.addValueToField(this.newValue, mdField); + // Set the place property to match the new value's position within its field + const place = this.fields[mdField].length - 1; + this.fields[mdField][place].originalValue.place = place; + this.fields[mdField][place].newValue.place = place; + this.newValue = undefined; + } + + /** + * Add a value to a metadata field within the map + * @param value + * @param mdField + * @private + */ + private addValueToField(value: DsoEditMetadataValue, mdField: string): void { + if (isEmpty(this.fields[mdField])) { + this.fieldKeys.push(mdField); + this.sortFieldKeys(); + this.fields[mdField] = []; + } + 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): void { + if (isNotEmpty(this.fields[mdField])) { + this.fields[mdField].splice(index, 1); + if (this.fields[mdField].length === 0) { + this.fieldKeys.splice(this.fieldKeys.indexOf(mdField), 1); + delete this.fields[mdField]; + } + } + } + + /** + * Returns if at least one value within the form contains a change + */ + hasChanges(): boolean { + return Object.values(this.fields).some((values: DsoEditMetadataValue[]) => values.some((value: DsoEditMetadataValue) => value.hasChanges())); + } + + /** + * Check if a metadata field contains changes within its order (place property of values) + * @param mdField + */ + hasOrderChanges(mdField: string): boolean { + return this.fields[mdField].some((value: DsoEditMetadataValue) => value.originalValue.place !== value.newValue.place); + } + + /** + * Discard all changes within the form and store their current values within re-instatable properties so they can be + * undone afterwards + */ + discard(): void { + this.resetReinstatable(); + // Discard changes from each value from each field + Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => { + let removeFromIndex = -1; + values.forEach((value: DsoEditMetadataValue, index: number) => { + if (value.change === DsoEditMetadataChangeType.ADD) { + if (isEmpty(this.reinstatableNewValues[field])) { + this.reinstatableNewValues[field] = []; + } + this.reinstatableNewValues[field].push(value); + if (removeFromIndex === -1) { + removeFromIndex = index; + } + } else { + value.discardAndMarkReinstatable(); + } + }); + if (removeFromIndex > -1) { + this.fields[field].splice(removeFromIndex, this.fields[field].length - removeFromIndex); + } + }); + // Delete new metadata fields + this.fieldKeys.forEach((field: string) => { + if (this.originalFieldKeys.indexOf(field) < 0) { + delete this.fields[field]; + } + }); + this.fieldKeys = [...this.originalFieldKeys]; + this.sortFieldKeys(); + // Reset the order of values within their fields to match their place property + this.fieldKeys.forEach((field: string) => { + this.setValuesForFieldSorted(field, this.fields[field]); + }); + } + + /** + * Reset the order of values within a metadata field to their original places + * Update the actual array to match the place properties + * @param mdField + */ + resetOrder(mdField: string) { + this.fields[mdField].forEach((value: DsoEditMetadataValue) => { + value.newValue.place = value.originalValue.place; + value.confirmChanges(); + }); + this.setValuesForFieldSorted(mdField, this.fields[mdField]); + } + + /** + * Sort fieldKeys alphabetically + * Should be called whenever a field is added to ensure the alphabetical order is kept + */ + sortFieldKeys() { + this.fieldKeys.sort((a: string, b: string) => a.localeCompare(b)); + } + + /** + * Undo any previously discarded changes + */ + reinstate(): void { + // Reinstate each value + Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => { + values.forEach((value: DsoEditMetadataValue) => { + value.reinstate(); + }); + }); + // Re-add new values + Object.entries(this.reinstatableNewValues).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => { + values.forEach((value: DsoEditMetadataValue) => { + this.addValueToField(value, field); + }); + }); + // Reset the order of values within their fields to match their place property + this.fieldKeys.forEach((field: string) => { + this.setValuesForFieldSorted(field, this.fields[field]); + }); + this.reinstatableNewValues = {}; + } + + /** + * Returns if at least one value contains a re-instatable property, meaning a discard can be reversed + */ + isReinstatable(): boolean { + return isNotEmpty(this.reinstatableNewValues) || + Object.values(this.fields) + .some((values: DsoEditMetadataValue[]) => values + .some((value: DsoEditMetadataValue) => value.isReinstatable())); + } + + /** + * Reset the state of the re-instatable properties and values + */ + resetReinstatable(): void { + this.reinstatableNewValues = {}; + Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => { + values.forEach((value: DsoEditMetadataValue) => { + value.resetReinstatable(); + }); + }); + } + + /** + * Set the values of a metadata field and sort them by their newValue's place property + * @param mdField + * @param values + */ + private setValuesForFieldSorted(mdField: string, values: DsoEditMetadataValue[]) { + this.fields[mdField] = values.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place); + } + + /** + * Get the json PATCH operations for the current changes within this form + * For each metadata field, it'll return operations in the following order: replace, remove (from last to first place), add and move + * This order is important, as each operation is executed in succession of the previous one + */ + getOperations(moveAnalyser: ArrayMoveChangeAnalyzer): Operation[] { + const operations: Operation[] = []; + Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => { + const replaceOperations: MetadataPatchReplaceOperation[] = []; + const removeOperations: MetadataPatchRemoveOperation[] = []; + const addOperations: MetadataPatchAddOperation[] = []; + [...values] + .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place) + .forEach((value: DsoEditMetadataValue) => { + if (hasValue(value.change)) { + if (value.change === DsoEditMetadataChangeType.UPDATE) { + // Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below. + if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language) { + replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, { + value: value.newValue.value, + language: value.newValue.language, + })); + } + } else if (value.change === DsoEditMetadataChangeType.REMOVE) { + removeOperations.push(new MetadataPatchRemoveOperation(field, value.originalValue.place)); + } else if (value.change === DsoEditMetadataChangeType.ADD) { + addOperations.push(new MetadataPatchAddOperation(field, { + value: value.newValue.value, + language: value.newValue.language, + })); + } else { + console.warn('Illegal metadata change state detected for', value); + } + } + }); + + operations.push(...replaceOperations + .map((operation: MetadataPatchReplaceOperation) => operation.toOperation())); + operations.push(...removeOperations + // Sort remove operations backwards first, because they get executed in order. This avoids one removal affecting the next. + .sort((a: MetadataPatchRemoveOperation, b: MetadataPatchRemoveOperation) => b.place - a.place) + .map((operation: MetadataPatchRemoveOperation) => operation.toOperation())); + operations.push(...addOperations + .map((operation: MetadataPatchAddOperation) => operation.toOperation())); + }); + // Calculate and add the move operations that need to happen in order to move value from their old place to their new within the field + // This uses an ArrayMoveChangeAnalyzer + Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => { + // Exclude values marked for removal, because operations are executed in order (remove first, then move) + const valuesWithoutRemoved = values.filter((value: DsoEditMetadataValue) => value.change !== DsoEditMetadataChangeType.REMOVE); + const moveOperations = moveAnalyser + .diff( + [...valuesWithoutRemoved] + .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place) + .map((value: DsoEditMetadataValue) => value.originalValue.place), + [...valuesWithoutRemoved] + .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place) + .map((value: DsoEditMetadataValue) => value.originalValue.place)) + .map((operation: MoveOperation) => new MetadataPatchMoveOperation(field, +operation.from.substr(1), +operation.path.substr(1)).toOperation()); + operations.push(...moveOperations); + }); + return operations; + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html new file mode 100644 index 0000000000..ecaf2aa744 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html @@ -0,0 +1,10 @@ +
+
{{ dsoType + '.edit.metadata.headers.field' | translate }}
+
+
+
{{ dsoType + '.edit.metadata.headers.value' | translate }}
+
{{ dsoType + '.edit.metadata.headers.language' | translate }}
+
{{ dsoType + '.edit.metadata.headers.edit' | translate }}
+
+
+
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.scss new file mode 100644 index 0000000000..d46751d4b0 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.scss @@ -0,0 +1,12 @@ +.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-header-row { + background-color: var(--bs-gray-100); +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts new file mode 100644 index 0000000000..a0a1da1f1e --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts @@ -0,0 +1,32 @@ +import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata-headers.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 { By } from '@angular/platform-browser'; + +describe('DsoEditMetadataHeadersComponent', () => { + let component: DsoEditMetadataHeadersComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [DsoEditMetadataHeadersComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoEditMetadataHeadersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should display three headers', () => { + expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(3); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.ts new file mode 100644 index 0000000000..9c21c8ac9e --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.ts @@ -0,0 +1,17 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ds-dso-edit-metadata-headers', + styleUrls: ['./dso-edit-metadata-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'], + templateUrl: './dso-edit-metadata-headers.component.html', +}) +/** + * Component displaying the header table row for DSO edit metadata page + */ +export class DsoEditMetadataHeadersComponent { + /** + * Type of DSO we're displaying values for + * Determines i18n messages + */ + @Input() dsoType: string; +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss new file mode 100644 index 0000000000..20d479ac88 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss @@ -0,0 +1,49 @@ +.ds-field-row { + border: 1px solid var(--bs-gray-400); +} + +.ds-flex-cell { + padding: 1rem; + border: 1px solid var(--bs-gray-200); +} + +.ds-lang-cell { + min-width: var(--ds-dso-edit-lang-width); + max-width: var(--ds-dso-edit-lang-width); +} + +.ds-edit-cell { + min-width: var(--ds-dso-edit-actions-width); +} + +.ds-value-row { + background-color: white; + + &:active { + cursor: grabbing; + } + + &.ds-warning { + background-color: var(--bs-warning-bg); + + .ds-flex-cell { + border: 1px solid var(--bs-warning); + } + } + + &.ds-danger { + background-color: var(--bs-danger-bg); + + .ds-flex-cell { + border: 1px solid var(--bs-danger); + } + } + + &.ds-success { + background-color: var(--bs-success-bg); + + .ds-flex-cell { + border: 1px solid var(--bs-success); + } + } +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html new file mode 100644 index 0000000000..2c3fb54600 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html @@ -0,0 +1,5 @@ +
+
{{ dsoType + '.edit.metadata.headers.value' | translate }}
+
{{ dsoType + '.edit.metadata.headers.language' | translate }}
+
{{ dsoType + '.edit.metadata.headers.edit' | translate }}
+
diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.scss similarity index 100% rename from src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html rename to src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.scss diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.ts new file mode 100644 index 0000000000..dfda2a50d1 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.ts @@ -0,0 +1,17 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ds-dso-edit-metadata-value-headers', + styleUrls: ['./dso-edit-metadata-value-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'], + templateUrl: './dso-edit-metadata-value-headers.component.html', +}) +/** + * Component displaying invisible headers for a list of metadata values using table roles for accessibility + */ +export class DsoEditMetadataValueHeadersComponent { + /** + * Type of DSO we're displaying values for + * Determines i18n messages + */ + @Input() dsoType: string; +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html new file mode 100644 index 0000000000..f54dde4971 --- /dev/null +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -0,0 +1,56 @@ +
+
+
{{ mdValue.newValue.value }}
+ + +
+
+
{{ 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/item-page/edit-item-page/edit-item-page.module.ts b/src/app/item-page/edit-item-page/edit-item-page.module.ts index fafbae0bd4..70f6c55c28 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.module.ts @@ -14,9 +14,6 @@ import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract import { ItemPrivateComponent } from './item-private/item-private.component'; import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; -import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; -import { ThemedItemMetadataComponent } from './item-metadata/themed-item-metadata.component'; -import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component'; import { SearchPageModule } from '../../search-page/search-page.module'; @@ -37,6 +34,7 @@ import { ItemAuthorizationsComponent } from './item-authorizations/item-authoriz import { ObjectValuesPipe } from '../../shared/utils/object-values-pipe'; import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; import { ItemVersionsModule } from '../versions/item-versions.module'; +import { DsoSharedModule } from '../../dso-shared/dso-shared.module'; /** @@ -53,6 +51,7 @@ import { ItemVersionsModule } from '../versions/item-versions.module'; ResourcePoliciesModule, NgbModule, ItemVersionsModule, + DsoSharedModule, ], declarations: [ EditItemPageComponent, @@ -65,16 +64,12 @@ import { ItemVersionsModule } from '../versions/item-versions.module'; ItemPublicComponent, ItemDeleteComponent, ItemStatusComponent, - ItemMetadataComponent, - ThemedItemMetadataComponent, ItemRelationshipsComponent, ItemBitstreamsComponent, ItemVersionHistoryComponent, - EditInPlaceFieldComponent, ItemEditBitstreamComponent, ItemEditBitstreamBundleComponent, PaginatedDragAndDropBitstreamListComponent, - EditInPlaceFieldComponent, EditRelationshipComponent, EditRelationshipListComponent, ItemCollectionMapperComponent, @@ -87,10 +82,6 @@ import { ItemVersionsModule } from '../versions/item-versions.module'; BundleDataService, ObjectValuesPipe ], - exports: [ - EditInPlaceFieldComponent, - ThemedItemMetadataComponent, - ] }) export class EditItemPageModule { diff --git a/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts index 2535e42216..e6fa53f3f3 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts @@ -7,7 +7,6 @@ import { ItemPrivateComponent } from './item-private/item-private.component'; import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.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 { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemMoveComponent } from './item-move/item-move.component'; @@ -38,6 +37,7 @@ import { ItemPageBitstreamsGuard } from './item-page-bitstreams.guard'; import { ItemPageRelationshipsGuard } from './item-page-relationships.guard'; import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard'; import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard'; +import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component'; /** * Routing module that handles the routing for the Edit Item page administrator functionality @@ -75,7 +75,7 @@ import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.gua }, { path: 'metadata', - component: ItemMetadataComponent, + component: ThemedDsoEditMetadataComponent, data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true }, canActivate: [ItemPageMetadataGuard] }, diff --git a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html deleted file mode 100644 index 46299c1b08..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - -
-
- {{metadata?.value}} -
-
- -
-
- - -
-
- {{metadata?.language}} -
-
- -
-
- - -
- - - - -
- diff --git a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss deleted file mode 100644 index a2a6786b36..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss +++ /dev/null @@ -1,13 +0,0 @@ -.btn[disabled] { - color: var(--bs-gray-600); - border-color: var(--bs-gray-600); - z-index: 0; // prevent border colors jumping on hover -} - -.metadata-field { - width: var(--ds-edit-item-metadata-field-width); -} - -.language-field { - width: var(--ds-edit-item-language-field-width); -} diff --git a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts deleted file mode 100644 index 121ab4580e..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { By } from '@angular/platform-browser'; -import { TranslateModule } from '@ngx-translate/core'; -import { getTestScheduler } from 'jasmine-marbles'; -import { of as observableOf } from 'rxjs'; -import { TestScheduler } from 'rxjs/testing'; -import { MetadataFieldDataService } from '../../../../core/data/metadata-field-data.service'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; -import { MetadataField } from '../../../../core/metadata/metadata-field.model'; -import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; -import { RegistryService } from '../../../../core/registry/registry.service'; -import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; -import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { followLink } from '../../../../shared/utils/follow-link-config.model'; -import { EditInPlaceFieldComponent } from './edit-in-place-field.component'; -import { MockComponent, MockDirective } from 'ng-mocks'; -import { DebounceDirective } from '../../../../shared/utils/debounce.directive'; -import { ValidationSuggestionsComponent } from '../../../../shared/input-suggestions/validation-suggestions/validation-suggestions.component'; -import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; - -let comp: EditInPlaceFieldComponent; -let fixture: ComponentFixture; -let de: DebugElement; -let el: HTMLElement; -let metadataFieldService; -let objectUpdatesService; -let paginatedMetadataFields; -const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }); -const mdSchemaRD$ = createSuccessfulRemoteDataObject$(mdSchema); -const mdField1 = Object.assign(new MetadataField(), { - schema: mdSchemaRD$, - element: 'contributor', - qualifier: 'author' -}); -const mdField2 = Object.assign(new MetadataField(), { - schema: mdSchemaRD$, - element: 'title' -}); -const mdField3 = Object.assign(new MetadataField(), { - schema: mdSchemaRD$, - element: 'description', - qualifier: 'abstract', -}); - -const metadatum = Object.assign(new MetadatumViewModel(), { - key: 'dc.description.abstract', - value: 'Example abstract', - language: 'en' -}); - -const url = 'http://test-url.com/test-url'; -const fieldUpdate = { - field: metadatum, - changeType: undefined -}; -let scheduler: TestScheduler; - -describe('EditInPlaceFieldComponent', () => { - - beforeEach(waitForAsync(() => { - scheduler = getTestScheduler(); - - paginatedMetadataFields = buildPaginatedList(undefined, [mdField1, mdField2, mdField3]); - - metadataFieldService = jasmine.createSpyObj({ - queryMetadataFields: createSuccessfulRemoteDataObject$(paginatedMetadataFields), - }); - objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', - { - saveChangeFieldUpdate: {}, - saveRemoveFieldUpdate: {}, - setEditableFieldUpdate: {}, - setValidFieldUpdate: {}, - removeSingleFieldUpdate: {}, - isEditable: observableOf(false), // should always return something --> its in ngOnInit - isValid: observableOf(true) // should always return something --> its in ngOnInit - } - ); - - TestBed.configureTestingModule({ - imports: [FormsModule, TranslateModule.forRoot()], - declarations: [ - EditInPlaceFieldComponent, - MockDirective(DebounceDirective), - MockComponent(ValidationSuggestionsComponent) - ], - providers: [ - { provide: RegistryService, useValue: metadataFieldService }, - { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: MetadataFieldDataService, useValue: {} } - ], schemas: [ - CUSTOM_ELEMENTS_SCHEMA - ] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(EditInPlaceFieldComponent); - comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance - de = fixture.debugElement; - el = de.nativeElement; - - comp.url = url; - comp.fieldUpdate = fieldUpdate; - comp.metadata = metadatum; - }); - - describe('update', () => { - beforeEach(() => { - comp.update(); - fixture.detectChanges(); - }); - - it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { - expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum); - }); - }); - - describe('setEditable', () => { - const editable = false; - beforeEach(() => { - comp.setEditable(editable); - fixture.detectChanges(); - }); - - it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => { - expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid, editable); - }); - }); - - describe('editable is true', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - it('the div should contain input fields or textareas', () => { - const inputField = de.queryAll(By.css('input')); - const textAreas = de.queryAll(By.css('textarea')); - expect(inputField.length + textAreas.length).toBeGreaterThan(0); - }); - }); - - describe('editable is false', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - it('the div should contain no input fields or textareas', () => { - const inputField = de.queryAll(By.css('input')); - const textAreas = de.queryAll(By.css('textarea')); - expect(inputField.length + textAreas.length).toBe(0); - }); - }); - - describe('isValid is true', () => { - beforeEach(() => { - objectUpdatesService.isValid.and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - it('the div should not contain an error message', () => { - const errorMessages = de.queryAll(By.css('small.text-danger')); - expect(errorMessages.length).toBe(0); - - }); - }); - - describe('isValid is false', () => { - beforeEach(() => { - objectUpdatesService.isValid.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - it('there should be an error message', () => { - const errorMessages = de.queryAll(By.css('small.text-danger')); - expect(errorMessages.length).toBeGreaterThan(0); - - }); - }); - - describe('remove', () => { - beforeEach(() => { - comp.remove(); - fixture.detectChanges(); - }); - - it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { - expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum); - }); - }); - - describe('removeChangesFromField', () => { - beforeEach(() => { - comp.removeChangesFromField(); - fixture.detectChanges(); - }); - - it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => { - expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid); - }); - }); - - describe('findMetadataFieldSuggestions', () => { - const query = 'query string'; - - const metadataFieldSuggestions: InputSuggestion[] = - [ - { - displayValue: ('dc.' + mdField1.toString()).split('.').join('.​'), - value: ('dc.' + mdField1.toString()) - }, - { - displayValue: ('dc.' + mdField2.toString()).split('.').join('.​'), - value: ('dc.' + mdField2.toString()) - }, - { - displayValue: ('dc.' + mdField3.toString()).split('.').join('.​'), - value: ('dc.' + mdField3.toString()) - } - ]; - - beforeEach(fakeAsync(() => { - comp.findMetadataFieldSuggestions(query); - tick(); - fixture.detectChanges(); - })); - - it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => { - expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query, null, true, false, followLink('schema')); - }); - - it('it should set metadataFieldSuggestions to the right value', () => { - const expected = 'a'; - scheduler.expectObservable(comp.metadataFieldSuggestions).toBe(expected, { a: metadataFieldSuggestions }); - }); - }); - - describe('canSetEditable', () => { - describe('when editable is currently true', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - - it('canSetEditable should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false }); - }); - }); - - describe('when editable is currently false', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - - describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; - fixture.detectChanges(); - }); - it('canSetEditable should return an observable emitting true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: true }); - }); - }); - - describe('when the fieldUpdate\'s changeType is currently REMOVE', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.REMOVE; - fixture.detectChanges(); - }); - it('canSetEditable should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false }); - }); - }); - }); - }); - - describe('canSetUneditable', () => { - describe('when editable is currently true', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - - it('canSetUneditable should return an observable emitting true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: true }); - }); - }); - - describe('when editable is currently false', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - - it('canSetUneditable should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: false }); - }); - }); - }); - - describe('when canSetEditable emits true', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(false)); - spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - it('the div should have an enabled button with an edit icon', () => { - const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; - expect(editIcon).toBe(false); - }); - }); - - describe('when canSetEditable emits false', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(false)); - spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - it('the div should have a disabled button with an edit icon', () => { - const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; - expect(editIcon).toBe(true); - }); - }); - - describe('when canSetUneditable emits true', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - it('the div should have an enabled button with a check icon', () => { - const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; - expect(checkButtonAttrs).toBe(false); - }); - }); - - describe('when canSetUneditable emits false', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - it('the div should have a disabled button with a check icon', () => { - const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; - expect(checkButtonAttrs).toBe(true); - }); - }); - - describe('when canRemove emits true', () => { - beforeEach(() => { - spyOn(comp, 'canRemove').and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - it('the div should have an enabled button with a trash icon', () => { - const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; - expect(trashButtonAttrs).toBe(false); - }); - }); - - describe('when canRemove emits false', () => { - beforeEach(() => { - spyOn(comp, 'canRemove').and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - it('the div should have a disabled button with a trash icon', () => { - const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; - expect(trashButtonAttrs).toBe(true); - }); - }); - - describe('when canUndo emits true', () => { - beforeEach(() => { - spyOn(comp, 'canUndo').and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - it('the div should have an enabled button with an undo icon', () => { - const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; - expect(undoIcon).toBe(false); - }); - }); - - describe('when canUndo emits false', () => { - beforeEach(() => { - spyOn(comp, 'canUndo').and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - it('the div should have a disabled button with an undo icon', () => { - const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; - expect(undoIcon).toBe(true); - }); - }); - - describe('canRemove', () => { - describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.UPDATE; - fixture.detectChanges(); - }); - it('canRemove should return an observable emitting true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true }); - }); - }); - - describe('when the fieldUpdate\'s changeType is currently ADD', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; - fixture.detectChanges(); - }); - it('canRemove should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false }); - }); - }); - }); - - describe('canUndo', () => { - - describe('when editable is currently true', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - comp.fieldUpdate.changeType = undefined; - fixture.detectChanges(); - }); - it('canUndo should return an observable emitting true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); - }); - }); - - describe('when editable is currently false', () => { - describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; - fixture.detectChanges(); - }); - - it('canUndo should return an observable emitting true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); - }); - }); - - describe('when the fieldUpdate\'s changeType is currently undefined', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = undefined; - fixture.detectChanges(); - }); - - it('canUndo should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false }); - }); - }); - }); - - }); - - describe('canEditMetadataField', () => { - describe('when the fieldUpdate\'s changeType is currently ADD', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - comp.fieldUpdate.changeType = FieldChangeType.ADD; - fixture.detectChanges(); - }); - it('can edit metadata field', () => { - const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions')) - .componentInstance.disable; - expect(disabledMetadataField).toBe(false); - }); - }); - describe('when the fieldUpdate\'s changeType is currently REMOVE', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - comp.fieldUpdate.changeType = FieldChangeType.REMOVE; - fixture.detectChanges(); - }); - it('can edit metadata field', () => { - const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions')) - .componentInstance.disable; - expect(disabledMetadataField).toBe(true); - }); - }); - describe('when the fieldUpdate\'s changeType is currently UPDATE', () => { - beforeEach(() => { - objectUpdatesService.isEditable.and.returnValue(observableOf(true)); - comp.fieldUpdate.changeType = FieldChangeType.UPDATE; - fixture.detectChanges(); - }); - it('can edit metadata field', () => { - const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions')) - .componentInstance.disable; - expect(disabledMetadataField).toBe(true); - }); - }); - }); -}); diff --git a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts deleted file mode 100644 index 440ccd135f..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Component, Input, OnChanges, OnInit } from '@angular/core'; -import { - metadataFieldsToString, - getFirstSucceededRemoteData -} from '../../../../core/shared/operators'; -import { hasValue, isNotEmpty } from '../../../../shared/empty.util'; -import { RegistryService } from '../../../../core/registry/registry.service'; -import cloneDeep from 'lodash/cloneDeep'; -import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { NgModel } from '@angular/forms'; -import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; -import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; -import { followLink } from '../../../../shared/utils/follow-link-config.model'; -import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; -import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; - -@Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: '[ds-edit-in-place-field]', - styleUrls: ['./edit-in-place-field.component.scss'], - templateUrl: './edit-in-place-field.component.html', -}) -/** - * Component that displays a single metadatum of an item on the edit page - */ -export class EditInPlaceFieldComponent implements OnInit, OnChanges { - /** - * The current field, value and state of the metadatum - */ - @Input() fieldUpdate: FieldUpdate; - - /** - * The current url of this page - */ - @Input() url: string; - - /** - * The metadatum of this field - */ - @Input() metadata: MetadatumViewModel; - - /** - * Emits whether or not this field is currently editable - */ - editable: Observable; - - /** - * Emits whether or not this field is currently valid - */ - valid: Observable; - - /** - * The current suggestions for the metadatafield when editing - */ - metadataFieldSuggestions: BehaviorSubject = new BehaviorSubject([]); - - constructor( - private registryService: RegistryService, - private objectUpdatesService: ObjectUpdatesService, - ) { - } - - /** - * Sets up an observable that keeps track of the current editable and valid state of this field - */ - ngOnInit(): void { - this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid); - this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid); - } - - /** - * Sends a new change update for this field to the object updates service - */ - update(ngModel?: NgModel) { - this.objectUpdatesService.saveChangeFieldUpdate(this.url, cloneDeep(this.metadata)); - if (hasValue(ngModel)) { - this.checkValidity(ngModel); - } - } - - /** - * Method to check the validity of a form control - * @param ngModel - */ - public checkValidity(ngModel: NgModel) { - ngModel.control.setValue(ngModel.viewModel); - ngModel.control.updateValueAndValidity(); - this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, ngModel.control.valid); - } - - /** - * Sends a new editable state for this field to the service to change it - * @param editable The new editable state for this field - */ - setEditable(editable: boolean) { - this.objectUpdatesService.setEditableFieldUpdate(this.url, this.metadata.uuid, editable); - } - - /** - * Sends a new remove update for this field to the object updates service - */ - remove() { - this.objectUpdatesService.saveRemoveFieldUpdate(this.url, cloneDeep(this.metadata)); - } - - /** - * Notifies the object updates service that the updates for the current field can be removed - */ - removeChangesFromField() { - this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid); - } - - /** - * Sets the current metadatafield based on the fieldUpdate input field - */ - ngOnChanges(): void { - this.metadata = cloneDeep(this.fieldUpdate.field) as MetadatumViewModel; - } - - /** - * Requests all metadata fields that contain the query string in their key - * Then sets all found metadata fields as metadataFieldSuggestions - * Ignores fields from metadata schemas "relation" and "relationship" - * @param query The query to look for - */ - findMetadataFieldSuggestions(query: string) { - if (isNotEmpty(query)) { - return this.registryService.queryMetadataFields(query, null, true, false, followLink('schema')).pipe( - getFirstSucceededRemoteData(), - metadataFieldsToString(), - ).subscribe((fieldNames: string[]) => { - this.setInputSuggestions(fieldNames); - }); - } else { - this.metadataFieldSuggestions.next([]); - } - } - - /** - * Set the list of input suggestion with the given Metadata fields, which all require a resolved MetadataSchema - * @param fields list of Metadata fields, which all require a resolved MetadataSchema - */ - setInputSuggestions(fields: string[]) { - this.metadataFieldSuggestions.next( - fields.map((fieldName: string) => { - return { - displayValue: fieldName.split('.').join('.​'), - value: fieldName - }; - }) - ); - } - - /** - * Check if a user should be allowed to edit this field - * @return an observable that emits true when the user should be able to edit this field and false when they should not - */ - canSetEditable(): Observable { - return this.editable.pipe( - map((editable: boolean) => { - if (editable) { - return false; - } else { - return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; - } - }) - ); - } - - /** - * Check if a user should be allowed to disabled editing this field - * @return an observable that emits true when the user should be able to disable editing this field and false when they should not - */ - canSetUneditable(): Observable { - return this.editable; - } - - /** - * Check if a user should be allowed to remove this field - * @return an observable that emits true when the user should be able to remove this field and false when they should not - */ - canRemove(): Observable { - return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD); - } - - /** - * Check if a user should be allowed to undo changes to this field - * @return an observable that emits true when the user should be able to undo changes to this field and false when they should not - */ - canUndo(): Observable { - return this.editable.pipe( - map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable) - ); - } - - protected isNotEmpty(value): boolean { - return isNotEmpty(value); - } -} diff --git a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html deleted file mode 100644 index 81dd39c238..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html +++ /dev/null @@ -1,69 +0,0 @@ - diff --git a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss deleted file mode 100644 index 96f785e1a1..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss +++ /dev/null @@ -1,20 +0,0 @@ -.button-row { - .btn { - margin-right: var(--ds-gap); - - &:last-child { - margin-right: 0; - } - - @media screen and (min-width: map-get($grid-breakpoints, sm)) { - min-width: var(--ds-edit-item-button-min-width); - } - } - - &.top .btn { - margin-top: calc(var(--bs-spacer) / 2); - margin-bottom: calc(var(--bs-spacer) / 2); - } - - -} diff --git a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts deleted file mode 100644 index 44ed6c783f..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { of as observableOf } from 'rxjs'; -import { getTestScheduler } from 'jasmine-marbles'; -import { ItemMetadataComponent } from './item-metadata.component'; -import { TestScheduler } from 'rxjs/testing'; -import { SharedModule } from '../../../shared/shared.module'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { ActivatedRoute, Router } from '@angular/router'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateModule } from '@ngx-translate/core'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { By } from '@angular/platform-browser'; -import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; -import { RouterStub } from '../../../shared/testing/router.stub'; -import { Item } from '../../../core/shared/item.model'; -import { MetadatumViewModel } from '../../../core/shared/metadata.models'; -import { RegistryService } from '../../../core/registry/registry.service'; -import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; -import { MetadataField } from '../../../core/metadata/metadata-field.model'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { ObjectCacheService } from '../../../core/cache/object-cache.service'; -import { DSOSuccessResponse } from '../../../core/cache/response.models'; -import { createPaginatedList } from '../../../shared/testing/utils.test'; -import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; - -let comp: any; -let fixture: ComponentFixture; -let de: DebugElement; -let el: HTMLElement; -let objectUpdatesService; -const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); -const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); -const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); -const date = new Date(); -const router = new RouterStub(); -let metadataFieldService; -let paginatedMetadataFields; -let routeStub; -let objectCacheService; - -const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }); -const mdField1 = Object.assign(new MetadataField(), { - schema: mdSchema, - element: 'contributor', - qualifier: 'author' -}); -const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' }); -const mdField3 = Object.assign(new MetadataField(), { - schema: mdSchema, - element: 'description', - qualifier: 'abstract' -}); - -let itemService; -const notificationsService = jasmine.createSpyObj('notificationsService', - { - info: infoNotification, - warning: warningNotification, - success: successNotification - } -); -const metadatum1 = Object.assign(new MetadatumViewModel(), { - key: 'dc.description.abstract', - value: 'Example abstract', - language: 'en' -}); - -const metadatum2 = Object.assign(new MetadatumViewModel(), { - key: 'dc.title', - value: 'Title test', - language: 'de' -}); - -const metadatum3 = Object.assign(new MetadatumViewModel(), { - key: 'dc.contributor.author', - value: 'Shakespeare, William', -}); - -const url = 'http://test-url.com/test-url'; - -router.url = url; - -const fieldUpdate1 = { - field: metadatum1, - changeType: undefined -}; - -const fieldUpdate2 = { - field: metadatum2, - changeType: FieldChangeType.REMOVE -}; - -const fieldUpdate3 = { - field: metadatum3, - changeType: undefined -}; - -const operation1 = { op: 'remove', path: '/metadata/dc.title/1' }; - -let scheduler: TestScheduler; -let item; -describe('ItemMetadataComponent', () => { - beforeEach(waitForAsync(() => { - item = Object.assign(new Item(), { - metadata: { - [metadatum1.key]: [metadatum1], - [metadatum2.key]: [metadatum2], - [metadatum3.key]: [metadatum3] - }, - _links: { - self: { - href: 'https://rest.api/core/items/a36d8bd2-8e8c-4969-9b1f-a574c2064983' - } - } - }, - { - lastModified: date - } - ) - ; - itemService = jasmine.createSpyObj('itemService', { - update: createSuccessfulRemoteDataObject$(item), - commitUpdates: {}, - patch: observableOf(new DSOSuccessResponse(['item-selflink'], 200, 'OK')), - findByHref: createSuccessfulRemoteDataObject$(item) - }); - routeStub = { - data: observableOf({}), - parent: { - data: observableOf({ dso: createSuccessfulRemoteDataObject(item) }) - } - }; - paginatedMetadataFields = createPaginatedList([mdField1, mdField2, mdField3]); - - metadataFieldService = jasmine.createSpyObj({ - getAllMetadataFields: createSuccessfulRemoteDataObject$(paginatedMetadataFields) - }); - scheduler = getTestScheduler(); - objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', - { - getFieldUpdates: observableOf({ - [metadatum1.uuid]: fieldUpdate1, - [metadatum2.uuid]: fieldUpdate2, - [metadatum3.uuid]: fieldUpdate3 - }), - saveAddFieldUpdate: {}, - discardFieldUpdates: {}, - reinstateFieldUpdates: observableOf(true), - initialize: {}, - getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]), - getLastModified: observableOf(date), - hasUpdates: observableOf(true), - isReinstatable: observableOf(false), // should always return something --> its in ngOnInit - isValidPage: observableOf(true), - createPatch: observableOf([ - operation1 - ]) - } - ); - objectCacheService = jasmine.createSpyObj('objectCacheService', ['addPatch']); - - TestBed.configureTestingModule({ - imports: [SharedModule, TranslateModule.forRoot()], - declarations: [ItemMetadataComponent], - providers: [ - { provide: ItemDataService, useValue: itemService }, - { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: Router, useValue: router }, - { provide: ActivatedRoute, useValue: routeStub }, - { provide: NotificationsService, useValue: notificationsService }, - { provide: RegistryService, useValue: metadataFieldService }, - { provide: ObjectCacheService, useValue: objectCacheService }, - ], schemas: [ - NO_ERRORS_SCHEMA - ] - }).compileComponents(); - }) - ); - - beforeEach(() => { - fixture = TestBed.createComponent(ItemMetadataComponent); - comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance - de = fixture.debugElement; - el = de.nativeElement; - comp.url = url; - fixture.detectChanges(); - }); - - describe('add', () => { - const md = new MetadatumViewModel(); - beforeEach(() => { - comp.add(md); - }); - - it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { - expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md); - }); - }); - - describe('discard', () => { - beforeEach(() => { - comp.discard(); - }); - - it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => { - expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); - }); - }); - - describe('reinstate', () => { - beforeEach(() => { - comp.reinstate(); - }); - - it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => { - expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); - }); - }); - - describe('submit', () => { - beforeEach(() => { - comp.submit(); - }); - - it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => { - expect(objectUpdatesService.createPatch).toHaveBeenCalledWith(url); - expect(itemService.patch).toHaveBeenCalledWith(comp.item, [operation1]); - expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList); - }); - }); - - describe('hasChanges', () => { - describe('when the objectUpdatesService\'s hasUpdated method returns true', () => { - beforeEach(() => { - objectUpdatesService.hasUpdates.and.returnValue(observableOf(true)); - }); - - it('should return an observable that emits true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: true }); - }); - }); - - describe('when the objectUpdatesService\'s hasUpdated method returns false', () => { - beforeEach(() => { - objectUpdatesService.hasUpdates.and.returnValue(observableOf(false)); - }); - - it('should return an observable that emits false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: false }); - }); - }); - }); - - describe('changeType is UPDATE', () => { - beforeEach(() => { - fieldUpdate1.changeType = FieldChangeType.UPDATE; - fixture.detectChanges(); - }); - it('the div should have class table-warning', () => { - const element = de.queryAll(By.css('tr'))[1].nativeElement; - expect(element.classList).toContain('table-warning'); - }); - }); - - describe('changeType is ADD', () => { - beforeEach(() => { - fieldUpdate1.changeType = FieldChangeType.ADD; - fixture.detectChanges(); - }); - it('the div should have class table-success', () => { - const element = de.queryAll(By.css('tr'))[1].nativeElement; - expect(element.classList).toContain('table-success'); - }); - }); - - describe('changeType is REMOVE', () => { - beforeEach(() => { - fieldUpdate1.changeType = FieldChangeType.REMOVE; - fixture.detectChanges(); - }); - it('the div should have class table-danger', () => { - const element = de.queryAll(By.css('tr'))[1].nativeElement; - expect(element.classList).toContain('table-danger'); - }); - }); -}); diff --git a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts deleted file mode 100644 index 3c1bad5114..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { Item } from '../../../core/shared/item.model'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { ActivatedRoute, Router } from '@angular/router'; -import cloneDeep from 'lodash/cloneDeep'; -import { first, switchMap } from 'rxjs/operators'; -import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; -import { RemoteData } from '../../../core/data/remote-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models'; -import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; -import { UpdateDataService } from '../../../core/data/update-data.service'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { AlertType } from '../../../shared/alert/aletr-type'; -import { Operation } from 'fast-json-patch'; -import { MetadataPatchOperationService } from '../../../core/data/object-updates/patch-operation-service/metadata-patch-operation.service'; - -@Component({ - selector: 'ds-item-metadata', - styleUrls: ['./item-metadata.component.scss'], - templateUrl: './item-metadata.component.html', -}) -/** - * Component for displaying an item's metadata edit page - */ -export class ItemMetadataComponent extends AbstractItemUpdateComponent { - - /** - * The AlertType enumeration - * @type {AlertType} - */ - public AlertTypeEnum = AlertType; - - /** - * A custom update service to use for adding and committing patches - * This will default to the ItemDataService - */ - @Input() updateService: UpdateDataService; - - constructor( - public itemService: ItemDataService, - public objectUpdatesService: ObjectUpdatesService, - public router: Router, - public notificationsService: NotificationsService, - public translateService: TranslateService, - public route: ActivatedRoute, - ) { - super(itemService, objectUpdatesService, router, notificationsService, translateService, route); - } - - /** - * Set up and initialize all fields - */ - ngOnInit(): void { - super.ngOnInit(); - if (hasNoValue(this.updateService)) { - this.updateService = this.itemService; - } - } - - /** - * Initialize the values and updates of the current item's metadata fields - */ - public initializeUpdates(): void { - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); - } - - /** - * Initialize the prefix for notification messages - */ - public initializeNotificationsPrefix(): void { - this.notificationsPrefix = 'item.edit.metadata.notifications.'; - } - - /** - * Sends a new add update for a field to the object updates service - * @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum - */ - add(metadata: MetadatumViewModel = new MetadatumViewModel()) { - this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); - } - - /** - * Sends all initial values of this item to the object updates service - */ - public initializeOriginalFields() { - this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified, MetadataPatchOperationService); - } - - /** - * Requests all current metadata for this item and requests the item service to update the item - * Makes sure the new version of the item is rendered on the page - */ - public submit() { - this.isValid().pipe(first()).subscribe((isValid) => { - if (isValid) { - this.objectUpdatesService.createPatch(this.url).pipe( - first(), - switchMap((patch: Operation[]) => { - return this.updateService.patch(this.item, patch).pipe( - getFirstCompletedRemoteData() - ); - }) - ).subscribe( - (rd: RemoteData) => { - if (rd.hasFailed) { - this.notificationsService.error(this.getNotificationTitle('error'), rd.errorMessage); - } else { - this.item = rd.payload; - this.checkAndFixMetadataUUIDs(); - this.initializeOriginalFields(); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); - this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); - } - } - ); - } else { - this.notificationsService.error(this.getNotificationTitle('invalid'), this.getNotificationContent('invalid')); - } - }); - } - - /** - * Check for empty metadata UUIDs and fix them (empty UUIDs would break the object-update service) - */ - checkAndFixMetadataUUIDs() { - const metadata = cloneDeep(this.item.metadata); - Object.keys(this.item.metadata).forEach((key: string) => { - metadata[key] = this.item.metadata[key].map((value) => hasValue(value.uuid) ? value : Object.assign(new MetadataValue(), value)); - }); - this.item.metadata = metadata; - } -} diff --git a/src/app/item-page/edit-item-page/item-metadata/themed-item-metadata.component.ts b/src/app/item-page/edit-item-page/item-metadata/themed-item-metadata.component.ts deleted file mode 100644 index 53f0120015..0000000000 --- a/src/app/item-page/edit-item-page/item-metadata/themed-item-metadata.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { Item } from '../../../core/shared/item.model'; -import { UpdateDataService } from '../../../core/data/update-data.service'; -import { ItemMetadataComponent } from './item-metadata.component'; -import { ThemedComponent } from '../../../shared/theme-support/themed.component'; - -@Component({ - selector: 'ds-themed-item-metadata', - styleUrls: [], - templateUrl: './../../../shared/theme-support/themed.component.html', -}) -/** - * Component for displaying an item's metadata edit page - */ -export class ThemedItemMetadataComponent extends ThemedComponent { - - @Input() item: Item; - - @Input() updateService: UpdateDataService; - - protected inAndOutputNames: (keyof ItemMetadataComponent & keyof this)[] = ['item', 'updateService']; - - protected getComponentName(): string { - return 'ItemMetadataComponent'; - } - - protected importThemedComponent(themeName: string): Promise { - return import(`../../../../themes/${themeName}/app/item-page/edit-item-page/item-metadata/item-metadata.component`); - } - - protected importUnthemedComponent(): Promise { - return import(`./item-metadata.component`); - } -} diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts index fafb5314b3..54420721b8 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts @@ -4,11 +4,13 @@ import { By } from '@angular/platform-browser'; import { MetadataRepresentationListComponent } from './metadata-representation-list.component'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { Item } from '../../../core/shared/item.model'; -import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; -import { createSuccessfulRemoteDataObject$, createFailedRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { TranslateModule } from '@ngx-translate/core'; import { VarDirective } from '../../../shared/utils/var.directive'; import { of as observableOf } from 'rxjs'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; const itemType = 'Person'; const metadataFields = ['dc.contributor.author', 'dc.creator']; @@ -73,39 +75,31 @@ const relatedCreator: Item = Object.assign(new Item(), { 'dspace.entity.type': 'Person', } }); -const authorRelation: Relationship = Object.assign(new Relationship(), { - leftItem: createSuccessfulRemoteDataObject$(parentItem), - rightItem: createSuccessfulRemoteDataObject$(relatedAuthor) -}); -const creatorRelation: Relationship = Object.assign(new Relationship(), { - leftItem: createSuccessfulRemoteDataObject$(parentItem), - rightItem: createSuccessfulRemoteDataObject$(relatedCreator), -}); -const creatorRelationUnauthorized: Relationship = Object.assign(new Relationship(), { - leftItem: createSuccessfulRemoteDataObject$(parentItem), - rightItem: createFailedRemoteDataObject$('Unauthorized', 401), -}); -let relationshipService; describe('MetadataRepresentationListComponent', () => { let comp: MetadataRepresentationListComponent; let fixture: ComponentFixture; - relationshipService = { - findById: (id: string) => { - if (id === 'related-author') { - return createSuccessfulRemoteDataObject$(authorRelation); - } - if (id === 'related-creator') { - return createSuccessfulRemoteDataObject$(creatorRelation); - } - if (id === 'related-creator-unauthorized') { - return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized); - } - }, - }; + let relationshipService; beforeEach(waitForAsync(() => { + relationshipService = { + resolveMetadataRepresentation: (metadatum: MetadataValue, parent: DSpaceObject, type: string) => { + if (metadatum.value === 'Related Author with authority') { + return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedAuthor)); + } + if (metadatum.value === 'Author without authority') { + return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum)); + } + if (metadatum.value === 'Related Creator with authority') { + return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedCreator)); + } + if (metadatum.value === 'Related Creator with authority - unauthorized') { + return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum)); + } + }, + }; + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [MetadataRepresentationListComponent, VarDirective], diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts index 5d2d0d0208..16dcf72cd4 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -1,21 +1,12 @@ import { Component, Input } from '@angular/core'; import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; import { - combineLatest as observableCombineLatest, Observable, - of as observableOf, zip as observableZip } from 'rxjs'; import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { MetadataValue } from '../../../core/shared/metadata.models'; -import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; -import { filter, map, switchMap } from 'rxjs/operators'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { Item } from '../../../core/shared/item.model'; -import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; -import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; @Component({ @@ -85,29 +76,7 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList ...metadata .slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy) .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) - .map((metadatum: MetadataValue) => { - if (metadatum.isVirtual) { - return this.relationshipService.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe( - getFirstSucceededRemoteData(), - switchMap((relRD: RemoteData) => - observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe( - filter(([leftItem, rightItem]) => leftItem.hasCompleted && rightItem.hasCompleted), - map(([leftItem, rightItem]) => { - if (!leftItem.hasSucceeded || !rightItem.hasSucceeded) { - return observableOf(Object.assign(new MetadatumRepresentation(this.itemType), metadatum)); - } else if (rightItem.hasSucceeded && leftItem.payload.id === this.parentItem.id) { - return rightItem.payload; - } else if (rightItem.payload.id === this.parentItem.id) { - return leftItem.payload; - } - }), - map((item: Item) => Object.assign(new ItemMetadataRepresentation(metadatum), item)) - ) - )); - } else { - return observableOf(Object.assign(new MetadatumRepresentation(this.itemType), metadatum)); - } - }) + .map((metadatum: MetadataValue) => this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType)), ); } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 9844ce4e0b..f68c0ff2ce 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1960,6 +1960,10 @@ "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.remove": "Remove", @@ -1968,6 +1972,8 @@ "item.edit.metadata.edit.buttons.unedit": "Stop editing", + "item.edit.metadata.edit.buttons.virtual": "This is a virtual metadata value, i.e. a value inherited from a related entity. It can’t be modified directly. Add or remove the corresponding relationship in the \"Relationships\" tab", + "item.edit.metadata.empty": "The item currently doesn't contain any metadata. Click Add to start adding a metadata value.", "item.edit.metadata.headers.edit": "Edit", @@ -1978,6 +1984,8 @@ "item.edit.metadata.headers.value": "Value", + "item.edit.metadata.metadatafield.error": "An error occurred validating the metadata field", + "item.edit.metadata.metadatafield.invalid": "Please choose a valid metadata field", "item.edit.metadata.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", @@ -2000,6 +2008,8 @@ "item.edit.metadata.reinstate-button": "Undo", + "item.edit.metadata.reset-order-button": "Undo reorder", + "item.edit.metadata.save-button": "Save", @@ -2464,6 +2474,62 @@ + "itemtemplate.edit.metadata.add-button": "Add", + + "itemtemplate.edit.metadata.discard-button": "Discard", + + "itemtemplate.edit.metadata.edit.buttons.confirm": "Confirm", + + "itemtemplate.edit.metadata.edit.buttons.drag": "Drag to reorder", + + "itemtemplate.edit.metadata.edit.buttons.edit": "Edit", + + "itemtemplate.edit.metadata.edit.buttons.remove": "Remove", + + "itemtemplate.edit.metadata.edit.buttons.undo": "Undo changes", + + "itemtemplate.edit.metadata.edit.buttons.unedit": "Stop editing", + + "itemtemplate.edit.metadata.empty": "The item template currently doesn't contain any metadata. Click Add to start adding a metadata value.", + + "itemtemplate.edit.metadata.headers.edit": "Edit", + + "itemtemplate.edit.metadata.headers.field": "Field", + + "itemtemplate.edit.metadata.headers.language": "Lang", + + "itemtemplate.edit.metadata.headers.value": "Value", + + "itemtemplate.edit.metadata.metadatafield.error": "An error occurred validating the metadata field", + + "itemtemplate.edit.metadata.metadatafield.invalid": "Please choose a valid metadata field", + + "itemtemplate.edit.metadata.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + + "itemtemplate.edit.metadata.notifications.discarded.title": "Changed discarded", + + "itemtemplate.edit.metadata.notifications.error.title": "An error occurred", + + "itemtemplate.edit.metadata.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.", + + "itemtemplate.edit.metadata.notifications.invalid.title": "Metadata invalid", + + "itemtemplate.edit.metadata.notifications.outdated.content": "The item template you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", + + "itemtemplate.edit.metadata.notifications.outdated.title": "Changed outdated", + + "itemtemplate.edit.metadata.notifications.saved.content": "Your changes to this item template's metadata were saved.", + + "itemtemplate.edit.metadata.notifications.saved.title": "Metadata saved", + + "itemtemplate.edit.metadata.reinstate-button": "Undo", + + "itemtemplate.edit.metadata.reset-order-button": "Undo reorder", + + "itemtemplate.edit.metadata.save-button": "Save", + + + "journal.listelement.badge": "Journal", "journal.page.description": "Description", diff --git a/src/styles/_bootstrap_variables_mapping.scss b/src/styles/_bootstrap_variables_mapping.scss index 5a64be7e2a..d352b24d38 100644 --- a/src/styles/_bootstrap_variables_mapping.scss +++ b/src/styles/_bootstrap_variables_mapping.scss @@ -29,11 +29,17 @@ --bs-teal: #{$teal}; --bs-cyan: #{$cyan}; --bs-primary: #{$primary}; + --bs-primary-bg: #{lighten($primary, 30%)}; --bs-secondary: #{$secondary}; + --bs-secondary-bg: #{lighten($secondary, 30%)}; --bs-success: #{$success}; + --bs-success-bg: #{lighten($success, 30%)}; --bs-info: #{$info}; + --bs-info-bg: #{lighten($info, 30%)}; --bs-warning: #{$warning}; + --bs-warning-bg: #{lighten($warning, 30%)}; --bs-danger: #{$danger}; + --bs-danger-bg: #{lighten($danger, 30%)}; --bs-light: #{$light}; --bs-dark: #{$dark}; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index caa3a9f455..ddf490c7a7 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -92,4 +92,9 @@ --ds-context-help-tooltip-link-color: $white; --ds-gap: 0.25rem; + + --ds-dso-edit-field-width: 210px; + --ds-dso-edit-lang-width: 90px; + --ds-dso-edit-actions-width: 173px; + --ds-dso-edit-virtual-tooltip-min-width: 300px; } diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss index 1bc0c8c435..bbb4bac298 100644 --- a/src/styles/_global-styles.scss +++ b/src/styles/_global-styles.scss @@ -228,3 +228,15 @@ ds-dynamic-form-control-container.d-none { .badge-item-type { background-color: #{map-get($theme-colors, info)}; } + +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} diff --git a/src/themes/custom/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/themes/custom/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss b/src/themes/custom/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/themes/custom/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts new file mode 100644 index 0000000000..b1cd6d2b39 --- /dev/null +++ b/src/themes/custom/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -0,0 +1,12 @@ +import { DsoEditMetadataComponent as BaseComponent } from '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-dso-edit-metadata', + // styleUrls: ['./dso-edit-metadata.component.scss'], + styleUrls: ['../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.scss'], + // templateUrl: './dso-edit-metadata.component.html', + templateUrl: '../../../../../app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html', +}) +export class DsoEditMetadataComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts deleted file mode 100644 index d6d7c4b8fb..0000000000 --- a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component } from '@angular/core'; -import { - ItemMetadataComponent as BaseComponent -} from '../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component'; - -@Component({ - selector: 'ds-item-metadata', - // styleUrls: ['./item-metadata.component.scss'], - styleUrls: ['../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component.scss'], - // templateUrl: './item-metadata.component.html', - templateUrl: '../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component.html', -}) -/** - * Component for displaying an item's metadata edit page - */ -export class ItemMetadataComponent extends BaseComponent { -} diff --git a/src/themes/custom/lazy-theme.module.ts b/src/themes/custom/lazy-theme.module.ts index 7bbd8d0326..5e2055c504 100644 --- a/src/themes/custom/lazy-theme.module.ts +++ b/src/themes/custom/lazy-theme.module.ts @@ -94,7 +94,6 @@ import { AuthNavMenuComponent } from './app/shared/auth-nav-menu/auth-nav-menu.c import { ExpandableNavbarSectionComponent } from './app/navbar/expandable-navbar-section/expandable-navbar-section.component'; -import { ItemMetadataComponent } from './app/item-page/edit-item-page/item-metadata/item-metadata.component'; import { EditItemTemplatePageComponent } from './app/collection-page/edit-item-template-page/edit-item-template-page.component'; @@ -122,7 +121,8 @@ import { ResultsBackButtonModule } from '../../app/shared/results-back-button/re import { ItemVersionsModule } from '../../app/item-page/versions/item-versions.module'; import { ItemSharedModule } from '../../app/item-page/item-shared.module'; import { ResultsBackButtonComponent } from './app/shared/results-back-button/results-back-button.component'; - +import { DsoEditMetadataComponent } from './app/dso-shared/dso-edit-metadata/dso-edit-metadata.component'; +import { DsoSharedModule } from '../../app/dso-shared/dso-shared.module'; const DECLARATIONS = [ FileSectionComponent, @@ -166,7 +166,6 @@ const DECLARATIONS = [ ComcolPageHandleComponent, AuthNavMenuComponent, ExpandableNavbarSectionComponent, - ItemMetadataComponent, EditItemTemplatePageComponent, LoadingComponent, SearchResultsComponent, @@ -178,8 +177,8 @@ const DECLARATIONS = [ BrowseByDatePageComponent, BrowseByTitlePageComponent, ExternalSourceEntryImportModalComponent, - ResultsBackButtonComponent - + ResultsBackButtonComponent, + DsoEditMetadataComponent, ]; @NgModule({ @@ -234,6 +233,7 @@ const DECLARATIONS = [ FormsModule, ResourcePoliciesModule, ComcolModule, + DsoSharedModule, ], declarations: DECLARATIONS, exports: [