Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement vocabulary value selectors in item’s metadata edit form #2653

Merged
merged 9 commits into from
Feb 23, 2024
Merged
30 changes: 26 additions & 4 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ submission:
# NOTE: example of configuration
# # NOTE: metadata name
# - name: dc.author
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
# style: fas fa-user
- name: dc.author
style: fas fa-user
Expand All @@ -147,18 +147,40 @@ submission:
confidence:
# NOTE: example of configuration
# # NOTE: confidence value
# - name: dc.author
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
# style: fa-user
# - value: 600
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
# style: text-success
# icon: fa-circle-check
# # NOTE: the class configured in property style is used by default, the icon property could be used in component
# configured to use a 'icon mode' display (mainly in edit-item page)
- value: 600
style: text-success
icon: fa-circle-check
- value: 500
style: text-info
icon: fa-gear
- value: 400
style: text-warning
icon: fa-circle-question
- value: 300
style: text-muted
icon: fa-thumbs-down
- value: 200
style: text-muted
icon: fa-circle-exclamation
- value: 100
style: text-muted
icon: fa-circle-stop
- value: 0
style: text-muted
icon: fa-ban
- value: -1
style: text-muted
icon: fa-circle-xmark
# default configuration
- value: default
style: text-muted
icon: fa-circle-xmark

# Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: en
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@
*/
import { VocabularyDataService } from './vocabulary.data.service';
import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec';
import { FindListOptions } from '../../data/find-list-options.model';
import { RequestParam } from '../../cache/models/request-param.model';
import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils';

describe('VocabularyDataService', () => {
let service: VocabularyDataService;
service = initTestService();
let restEndpointURL = 'https://rest.api/server/api/submission/vocabularies';
let vocabularyByMetadataAndCollectionEndpoint = `${restEndpointURL}/search/byMetadataAndCollection?metadata=dc.contributor.author&collection=1234-1234`;

function initTestService() {
return new VocabularyDataService(null, null, null, null);
}
Expand All @@ -17,4 +25,18 @@ describe('VocabularyDataService', () => {
const initService = () => new VocabularyDataService(null, null, null, null);
testFindAllDataImplementation(initService);
});

describe('getVocabularyByMetadataAndCollection', () => {
it('search vocabulary by metadata and collection calls expected methods', () => {
spyOn((service as any).searchData, 'getSearchByHref').and.returnValue(vocabularyByMetadataAndCollectionEndpoint);
spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null));
service.getVocabularyByMetadataAndCollection('dc.contributor.author', '1234-1234');
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new RequestParam('metadata', encodeURIComponent('dc.contributor.author'))),
Object.assign(new RequestParam('collection', encodeURIComponent('1234-1234')))]
});
expect((service as any).searchData.getSearchByHref).toHaveBeenCalledWith('byMetadataAndCollection', options);
expect(service.findByHref).toHaveBeenCalledWith(vocabularyByMetadataAndCollectionEndpoint, true, true);
});
});
});
25 changes: 25 additions & 0 deletions src/app/core/submission/vocabularies/vocabulary.data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import { PaginatedList } from '../../data/paginated-list.model';
import { Injectable } from '@angular/core';
import { VOCABULARY } from './models/vocabularies.resource-type';
import { dataService } from '../../data/base/data-service.decorator';
import { SearchDataImpl } from '../../data/base/search-data';
import { RequestParam } from '../../cache/models/request-param.model';

/**
* Data service to retrieve vocabularies from the REST server.
*/
@Injectable()
@dataService(VOCABULARY)
export class VocabularyDataService extends IdentifiableDataService<Vocabulary> implements FindAllData<Vocabulary> {
protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection';

private findAllData: FindAllData<Vocabulary>;
private searchData: SearchDataImpl<Vocabulary>;

constructor(
protected requestService: RequestService,
Expand All @@ -38,6 +43,7 @@ export class VocabularyDataService extends IdentifiableDataService<Vocabulary> i
super('vocabularies', requestService, rdbService, objectCache, halService);

this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}

/**
Expand All @@ -57,4 +63,23 @@ export class VocabularyDataService extends IdentifiableDataService<Vocabulary> i
public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<PaginatedList<Vocabulary>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

/**
* Return the controlled vocabulary configured for the specified metadata and collection if any (/submission/vocabularies/search/{@link searchByMetadataAndCollectionPath}?metadata=<>&collection=<>)
* @param metadataField metadata field to search
* @param collectionUUID collection UUID where is configured the vocabulary
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
public getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
const findListOptions = new FindListOptions();
findListOptions.searchParams = [new RequestParam('metadata', encodeURIComponent(metadataField)),
new RequestParam('collection', encodeURIComponent(collectionUUID))];
const href$ = this.searchData.getSearchByHref(this.searchByMetadataAndCollectionPath, findListOptions, ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}
19 changes: 19 additions & 0 deletions src/app/core/submission/vocabularies/vocabulary.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ describe('VocabularyService', () => {
spyOn((service as any).vocabularyDataService, 'findById').and.callThrough();
spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough();
spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough();
spyOn((service as any).vocabularyDataService, 'getVocabularyByMetadataAndCollection').and.callThrough();
spyOn((service as any).vocabularyDataService.findAllData, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL));
spyOn((service as any).vocabularyDataService.searchData, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL));
});

afterEach(() => {
Expand Down Expand Up @@ -312,6 +314,23 @@ describe('VocabularyService', () => {
expect(result).toBeObservable(expected);
});
});

describe('getVocabularyByMetadataAndCollection', () => {
it('should proxy the call to vocabularyDataService.getVocabularyByMetadataAndCollection', () => {
scheduler.schedule(() => service.getVocabularyByMetadataAndCollection(metadata, collectionUUID));
scheduler.flush();

expect((service as any).vocabularyDataService.getVocabularyByMetadataAndCollection).toHaveBeenCalledWith(metadata, collectionUUID, true, true);
});

it('should return a RemoteData<Vocabulary> for the object with the given metadata and collection', () => {
const result = service.getVocabularyByMetadataAndCollection(metadata, collectionUUID);
const expected = cold('a|', {
a: vocabularyRD
});
expect(result).toBeObservable(expected);
});
});
});

describe('vocabulary entries', () => {
Expand Down
17 changes: 17 additions & 0 deletions src/app/core/submission/vocabularies/vocabulary.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ export class VocabularyService {
return this.vocabularyDataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

/**
* Return the controlled vocabulary configured for the specified metadata and collection if any
* @param metadataField metadata field to search
* @param collectionUUID collection UUID where is configured the vocabulary
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<Vocabulary>>}
* Return an observable that emits vocabulary object
*/
getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
return this.vocabularyDataService.getVocabularyByMetadataAndCollection(metadataField, collectionUUID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

/**
* Return the {@link VocabularyEntry} list for a given {@link Vocabulary}
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<ds-dso-edit-metadata-value *ngFor="let mdValue of form.fields[mdField]; let idx = index" role="presentation"
[dso]="dso"
[mdValue]="mdValue"
[mdField]="mdField"
[dsoType]="dsoType"
[saving$]="saving$"
[isOnlyValue]="form.fields[mdField].length === 1"
Expand Down
10 changes: 8 additions & 2 deletions src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export class DsoEditMetadataValue {
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)) {
if (this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language
|| this.originalValue.authority !== this.newValue.authority || this.originalValue.confidence !== this.newValue.confidence) {
this.change = DsoEditMetadataChangeType.UPDATE;
} else {
this.change = undefined;
Expand Down Expand Up @@ -404,10 +405,13 @@ export class DsoEditMetadataForm {
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) {
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language
|| value.originalValue.authority !== value.newValue.authority || value.originalValue.confidence !== value.newValue.confidence) {
replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
value: value.newValue.value,
language: value.newValue.language,
authority: value.newValue.authority,
confidence: value.newValue.confidence
}));
}
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
Expand All @@ -416,6 +420,8 @@ export class DsoEditMetadataForm {
addOperations.push(new MetadataPatchAddOperation(field, {
value: value.newValue.value,
language: value.newValue.language,
authority: value.newValue.authority,
confidence: value.newValue.confidence
}));
} else {
console.warn('Illegal metadata change state detected for', value);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,58 @@
<div class="d-flex flex-row ds-value-row" *ngVar="mdValue.newValue.isVirtual as isVirtual" role="row"
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex align-items-center" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div>
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation" [(ngModel)]="mdValue.newValue.value"
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation && !(isAuthorityControlled() | async)" [(ngModel)]="mdValue.newValue.value"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
<ds-dynamic-scrollable-dropdown *ngIf="mdValue.editing && (isScrollableVocabulary() | async)"
[bindId]="mdField"
[group]="group"
[model]="getModel() | async"
(change)="onChangeAuthorityField($event)">
</ds-dynamic-scrollable-dropdown>
<ds-dynamic-onebox *ngIf="mdValue.editing && ((isHierarchicalVocabulary() | async) || (isSuggesterVocabulary() | async))"
[group]="group"
[model]="getModel() | async"
(change)="onChangeAuthorityField($event)">
</ds-dynamic-onebox>
<div *ngIf="!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE">
<span class="badge badge-light border" >
<i dsAuthorityConfidenceState
class="fas fa-fw p-0"
aria-hidden="true"
[authorityValue]="mdValue.newValue"
[iconMode]="true"
></i>
{{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }}
</span>
</div>
<div class="mt-2" *ngIf=" mdValue.editing && (isAuthorityControlled() | async) && (isSuggesterVocabulary() | async)">
<div class="btn-group w-75">
<i dsAuthorityConfidenceState
class="fas fa-fw p-0 mr-1 mt-auto mb-auto"
aria-hidden="true"
[authorityValue]="mdValue.newValue.confidence"
[iconMode]="true"
></i>
<input class="form-control form-outline" [(ngModel)]="mdValue.newValue.authority" [disabled]="!editingAuthority"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
(change)="onChangeAuthorityKey()" />
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="!editingAuthority"
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(true)">
<i class="fas fa-lock fa-fw"></i>
</button>
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="editingAuthority"
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(false)">
<i class="fas fa-lock-open fa-fw"></i>
</button>
</div>
</div>
<div class="d-flex" *ngIf="mdRepresentation">
<a class="mr-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
<ds-themed-type-badge [object]="mdRepresentation"></ds-themed-type-badge>
Expand Down Expand Up @@ -45,14 +92,14 @@
[disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()">
<i class="fas fa-undo-alt fa-fw"></i>
</button>
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
<i class="fas fa-grip-vertical fa-fw"></i>
</button>
</div>
</div>
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
<i class="fas fa-grip-vertical fa-fw"></i>
</button>
</div>
</div>
</div>
Loading
Loading