From 7fbdfeeca7863b32ede48c15d04179c295f3c4e3 Mon Sep 17 00:00:00 2001 From: Toni Prieto Date: Mon, 20 Nov 2023 12:35:04 +0100 Subject: [PATCH] Initial implementation of controlled vocabularies value selectors during item editing --- ...-edit-metadata-field-values.component.html | 1 + .../dso-edit-metadata-value.component.html | 57 ++++- .../dso-edit-metadata-value.component.spec.ts | 37 +++ .../dso-edit-metadata-value.component.ts | 225 +++++++++++++++++- src/app/dso-shared/dso-shared.module.ts | 5 + src/assets/i18n/en.json5 | 26 ++ 6 files changed, 341 insertions(+), 10 deletions(-) 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 index 9f74216d54f..aee9fb980cf 100644 --- 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 @@ -3,6 +3,7 @@ -
+
{{ mdValue.newValue.value }}
- + + + + + + + +
+ + {{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }} + + +
+ +
+
+ + + +
+ +
+
{{ mdRepresentationName$ | async }} @@ -45,14 +86,14 @@ [disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()"> +
-
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 index 67a6f98ac06..f81e8e9ef26 100644 --- 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 @@ -11,6 +11,12 @@ import { ItemMetadataRepresentation } from '../../../core/shared/metadata-repres import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models'; import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; import { By } from '@angular/platform-browser'; +import { VocabularyDataService } from '../../../core/submission/vocabularies/vocabulary.data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; const EDIT_BTN = 'edit'; const CONFIRM_BTN = 'confirm'; @@ -24,9 +30,25 @@ describe('DsoEditMetadataValueComponent', () => { let relationshipService: RelationshipDataService; let dsoNameService: DSONameService; + let vocabularyDataService: VocabularyDataService; + let itemService: ItemDataService; let editMetadataValue: DsoEditMetadataValue; let metadataValue: MetadataValue; + let dso: DSpaceObject; + + const collection = Object.assign(new Collection(), { + uuid: 'fake-uuid' + }); + + const item = Object.assign(new Item(), { + _links: { + self: { href: 'fake-item-url/item' } + }, + id: 'item', + uuid: 'item', + owningcollection: createSuccessfulRemoteDataObject$(collection) + }); function initServices(): void { relationshipService = jasmine.createSpyObj('relationshipService', { @@ -35,6 +57,13 @@ describe('DsoEditMetadataValueComponent', () => { dsoNameService = jasmine.createSpyObj('dsoNameService', { getName: 'Related Name', }); + vocabularyDataService = jasmine.createSpyObj('vocabularyDataService', { + getVocabularyByMetadataAndCollection: of(undefined), + }); + itemService = jasmine.createSpyObj('itemService', { + // should be: createSuccessfulRemoteDataObject$(item) + findByHref: of(item) + }); } beforeEach(waitForAsync(() => { @@ -45,6 +74,11 @@ describe('DsoEditMetadataValueComponent', () => { authority: undefined, }); editMetadataValue = new DsoEditMetadataValue(metadataValue); + dso = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'fake-dso-url/dso' } + }, + }); initServices(); @@ -54,6 +88,8 @@ describe('DsoEditMetadataValueComponent', () => { providers: [ { provide: RelationshipDataService, useValue: relationshipService }, { provide: DSONameService, useValue: dsoNameService }, + { provide: VocabularyDataService, useValue: vocabularyDataService }, + { provide: ItemDataService, useValue: itemService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -63,6 +99,7 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; + component.dso = dso; component.saving$ = of(false); fixture.detectChanges(); }); 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 index 3fdcd381abc..c0d5be7b575 100644 --- 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 @@ -8,10 +8,25 @@ import { 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 { map, switchMap } 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'; +import { VocabularyDataService } from '../../../core/submission/vocabularies/vocabulary.data.service'; +import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { ConfidenceType } from '../../../core/shared/confidence-type'; +import { getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../../core/shared/operators'; +import { DynamicOneboxModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { DynamicScrollableDropdownModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { Item } from '../../../core/shared/item.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { of as observableOf } from 'rxjs'; @Component({ selector: 'ds-dso-edit-metadata-value', @@ -51,6 +66,11 @@ export class DsoEditMetadataValueComponent implements OnInit { */ @Input() isOnlyValue = false; + /** + * MetadataField to edit + */ + @Input() mdField?: string; + /** * Emits when the user clicked edit */ @@ -82,6 +102,12 @@ export class DsoEditMetadataValueComponent implements OnInit { */ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; + /** + * The ConfidenceType enumeration for access in the component's template + * @type {ConfidenceType} + */ + public ConfidenceTypeEnum = ConfidenceType; + /** * The item this metadata value represents in case it's virtual (if any, otherwise null) */ @@ -97,12 +123,44 @@ export class DsoEditMetadataValueComponent implements OnInit { */ mdRepresentationName$: Observable; + /** + * Whether or not the authority field is currently being edited + */ + public editingAuthority = false; + + /** + * Field group used by authority field + * @type {UntypedFormGroup} + */ + group = new UntypedFormGroup({ authorityField : new UntypedFormControl()}); + + /** + * Observable property of the model to use for editinf authorities values + */ + private model$: Observable; + + /** + * Observable with information about the authority vocabulary used + */ + private vocabulary$: Observable; + + /** + * Observables with information about the authority vocabulary type used + */ + private isAuthorityControlled$: Observable; + private isHierarchicalVocabulary$: Observable; + private isScrollableVocabulary$: Observable; + private isSuggesterVocabulary$: Observable; + constructor(protected relationshipService: RelationshipDataService, - protected dsoNameService: DSONameService) { + protected dsoNameService: DSONameService, + protected vocabularyDataService: VocabularyDataService, + protected itemService: ItemDataService) { } ngOnInit(): void { this.initVirtualProperties(); + this.initAuthorityProperties(); } /** @@ -123,4 +181,167 @@ export class DsoEditMetadataValueComponent implements OnInit { map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null), ); } + + /** + * Initialise potential properties of a authority controlled metadata field + */ + initAuthorityProperties(): void { + + if (isNotEmpty(this.mdField)) { + + const owningCollection$: Observable = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection')) + .pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((item: Item) => item.owningCollection), + getFirstSucceededRemoteData(), + getRemoteDataPayload() + ); + + this.vocabulary$ = owningCollection$.pipe( + switchMap( (c: Collection) => this.vocabularyDataService + .getVocabularyByMetadataAndCollection(this.mdField, c.uuid) + .pipe( + getFirstSucceededRemoteDataPayload() + )) + ); + } else { + this.vocabulary$ = observableOf(undefined); + } + + this.isAuthorityControlled$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result)) + ); + + this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical) + ); + + this.isScrollableVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.scrollable) + ); + + this.isSuggesterVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable) + ); + + // FIXME: if vocabulary is undefined? + this.model$ = this.vocabulary$.pipe( + map((vocabulary: Vocabulary) => { + let formFieldValue = new FormFieldMetadataValueObject(); + formFieldValue.value = this.mdValue.newValue.value; + formFieldValue.display = this.mdValue.newValue.value; + if (this.mdValue.newValue.authority) { + formFieldValue.authority = this.mdValue.newValue.authority; + formFieldValue.confidence = this.mdValue.newValue.confidence; + } + + let vocabularyOptions = vocabulary ? { + closed: false, + name: vocabulary.name + } as VocabularyOptions : null; + + // FIXME: use a new file for models? + if (!vocabulary.scrollable) { + let model = { + id: 'authorityField', + label: 'Onebox field for ' + this.mdField, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false + }; + + return new DynamicOneboxModel(model); + } else { + let model = { + id: 'authorityField', + vocabularyOptions: vocabularyOptions, + label: 'Scrollable field for ' + this.mdField, + maxOptions: 10, + repeatable: false, + value: formFieldValue, + showErrorsMessages: false, + metadataFields: [this.mdField], + submissionId: 'edit-metadata', + hasSelectableMetadata: false + }; + + return new DynamicScrollableDropdownModel(model); + } + })); + } + + /** + * Checks if this field use a authority vocabulary + */ + isAuthorityControlled(): Observable { + return this.isAuthorityControlled$; + } + + /** + * Checks if configured vocabulary is Hierarchical or not + */ + isHierarchicalVocabulary(): Observable { + return this.isHierarchicalVocabulary$; + } + + /** + * Checks if configured vocabulary is Scrollable or not + */ + isScrollableVocabulary(): Observable { + return this.isScrollableVocabulary$; + } + + /** + * Checks if configured vocabulary is Suggester or not + * (a vocabulary not Scrollable and not Hierarchical that uses an autocomplete field) + */ + isSuggesterVocabulary(): Observable { + return this.isSuggesterVocabulary$; + } + + /** + * Process the change of authority field value updating the authority key if is needed + */ + onChangeAuthorityField(event): void { + this.mdValue.newValue.value = event.value; + if (event.authority) { + this.mdValue.newValue.authority = event.authority; + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + } + this.confirm.emit(false); + } + + /** + * Returns an observable with the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used + * for the authority field + */ + getModel(): Observable { + return this.model$; + } + + /** + * Change the status of the editingAuthority property + * @param status + */ + onChangeEditingAuthorityStatus(status: boolean) { + this.editingAuthority = status; + } + + /** + * Process the change of the authority value updating the confidence if is needed + */ + onChangeAuthorityKey() { + if (this.mdValue.newValue.authority === '') { + this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; + this.confirm.emit(false); + } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + this.confirm.emit(false); + } + } + } diff --git a/src/app/dso-shared/dso-shared.module.ts b/src/app/dso-shared/dso-shared.module.ts index 7d44d6a9206..6071ee3aec0 100644 --- a/src/app/dso-shared/dso-shared.module.ts +++ b/src/app/dso-shared/dso-shared.module.ts @@ -7,10 +7,13 @@ import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-meta 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'; +import { FormModule } from '../shared/form/form.module'; +import { ConfidenceIconComponent } from './dso-edit-metadata/confidence-icon/confidence-icon.component'; @NgModule({ imports: [ SharedModule, + FormModule ], declarations: [ DsoEditMetadataComponent, @@ -20,6 +23,7 @@ import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-e DsoEditMetadataValueComponent, DsoEditMetadataHeadersComponent, DsoEditMetadataValueHeadersComponent, + ConfidenceIconComponent ], exports: [ DsoEditMetadataComponent, @@ -29,6 +33,7 @@ import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-e DsoEditMetadataValueComponent, DsoEditMetadataHeadersComponent, DsoEditMetadataValueHeadersComponent, + ConfidenceIconComponent ], }) export class DsoSharedModule { diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 643a3ce0d18..162e84e24e6 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2156,6 +2156,16 @@ "item.edit.metadata.save-button": "Save", + "item.edit.metadata.authority.label": "Authority: ", + + "item.edit.metadata.field.value.title-text": "Value for metadata ", + + "item.edit.metadata.field.lang.title-text": "Language value for metadata ", + + "item.edit.metadata.edit.buttons.open-authority-edition": "Unlock the authority key value for manual editing", + + "item.edit.metadata.edit.buttons.close-authority-edition": "Lock the authority key value for manual editing", + "item.edit.modify.overview.field": "Field", "item.edit.modify.overview.language": "Language", @@ -2374,6 +2384,22 @@ "item.truncatable-part.show-less": "Collapse", + "helptext.confidence.indicator.accepted": "This authority value has been confirmed as accurate by an interactive user", + + "helptext.confidence.indicator.uncertain": "Value is singular and valid but has not been seen and accepted by a human so it is still uncertain", + + "helptext.confidence.indicator.ambigous": "There are multiple matching authority values of equal validity", + + "helptext.confidence.indicator.notfound": "There are no matching answers in the authority", + + "helptext.confidence.indicator.failed": "The authority encountered an internal failure", + + "helptext.confidence.indicator.rejected": "The authority recommends this submission be rejected", + + "helptext.confidence.indicator.novalue": "No reasonable confidence value was returned from the authority", + + "helptext.confidence.indicator.unset": "Confidence was never recorded for this value", + "workflow-item.search.result.delete-supervision.modal.header": "Delete Supervision Order", "workflow-item.search.result.delete-supervision.modal.info": "Are you sure you want to delete Supervision Order",