Skip to content

Commit fc4f0ec

Browse files
authored
Merge pull request DSpace#2653 from toniprieto/edit-item-authorities
Implement vocabulary value selectors in item’s metadata edit form
2 parents fc6da94 + 1fc0462 commit fc4f0ec

21 files changed

Lines changed: 960 additions & 41 deletions

config/config.example.yml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ submission:
136136
# NOTE: example of configuration
137137
# # NOTE: metadata name
138138
# - name: dc.author
139-
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
139+
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
140140
# style: fas fa-user
141141
- name: dc.author
142142
style: fas fa-user
@@ -147,18 +147,40 @@ submission:
147147
confidence:
148148
# NOTE: example of configuration
149149
# # NOTE: confidence value
150-
# - name: dc.author
151-
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
152-
# style: fa-user
150+
# - value: 600
151+
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
152+
# style: text-success
153+
# icon: fa-circle-check
154+
# # NOTE: the class configured in property style is used by default, the icon property could be used in component
155+
# configured to use a 'icon mode' display (mainly in edit-item page)
153156
- value: 600
154157
style: text-success
158+
icon: fa-circle-check
155159
- value: 500
156160
style: text-info
161+
icon: fa-gear
157162
- value: 400
158163
style: text-warning
164+
icon: fa-circle-question
165+
- value: 300
166+
style: text-muted
167+
icon: fa-thumbs-down
168+
- value: 200
169+
style: text-muted
170+
icon: fa-circle-exclamation
171+
- value: 100
172+
style: text-muted
173+
icon: fa-circle-stop
174+
- value: 0
175+
style: text-muted
176+
icon: fa-ban
177+
- value: -1
178+
style: text-muted
179+
icon: fa-circle-xmark
159180
# default configuration
160181
- value: default
161182
style: text-muted
183+
icon: fa-circle-xmark
162184

163185
# Default Language in which the UI will be rendered if the user's browser language is not an active language
164186
defaultLanguage: en

src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@
77
*/
88
import { VocabularyDataService } from './vocabulary.data.service';
99
import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec';
10+
import { FindListOptions } from '../../data/find-list-options.model';
11+
import { RequestParam } from '../../cache/models/request-param.model';
12+
import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils';
1013

1114
describe('VocabularyDataService', () => {
15+
let service: VocabularyDataService;
16+
service = initTestService();
17+
let restEndpointURL = 'https://rest.api/server/api/submission/vocabularies';
18+
let vocabularyByMetadataAndCollectionEndpoint = `${restEndpointURL}/search/byMetadataAndCollection?metadata=dc.contributor.author&collection=1234-1234`;
19+
1220
function initTestService() {
1321
return new VocabularyDataService(null, null, null, null);
1422
}
@@ -17,4 +25,18 @@ describe('VocabularyDataService', () => {
1725
const initService = () => new VocabularyDataService(null, null, null, null);
1826
testFindAllDataImplementation(initService);
1927
});
28+
29+
describe('getVocabularyByMetadataAndCollection', () => {
30+
it('search vocabulary by metadata and collection calls expected methods', () => {
31+
spyOn((service as any).searchData, 'getSearchByHref').and.returnValue(vocabularyByMetadataAndCollectionEndpoint);
32+
spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null));
33+
service.getVocabularyByMetadataAndCollection('dc.contributor.author', '1234-1234');
34+
const options = Object.assign(new FindListOptions(), {
35+
searchParams: [Object.assign(new RequestParam('metadata', encodeURIComponent('dc.contributor.author'))),
36+
Object.assign(new RequestParam('collection', encodeURIComponent('1234-1234')))]
37+
});
38+
expect((service as any).searchData.getSearchByHref).toHaveBeenCalledWith('byMetadataAndCollection', options);
39+
expect(service.findByHref).toHaveBeenCalledWith(vocabularyByMetadataAndCollectionEndpoint, true, true);
40+
});
41+
});
2042
});

src/app/core/submission/vocabularies/vocabulary.data.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@ import { PaginatedList } from '../../data/paginated-list.model';
2020
import { Injectable } from '@angular/core';
2121
import { VOCABULARY } from './models/vocabularies.resource-type';
2222
import { dataService } from '../../data/base/data-service.decorator';
23+
import { SearchDataImpl } from '../../data/base/search-data';
24+
import { RequestParam } from '../../cache/models/request-param.model';
2325

2426
/**
2527
* Data service to retrieve vocabularies from the REST server.
2628
*/
2729
@Injectable()
2830
@dataService(VOCABULARY)
2931
export class VocabularyDataService extends IdentifiableDataService<Vocabulary> implements FindAllData<Vocabulary> {
32+
protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection';
33+
3034
private findAllData: FindAllData<Vocabulary>;
35+
private searchData: SearchDataImpl<Vocabulary>;
3136

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

4045
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
46+
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
4147
}
4248

4349
/**
@@ -57,4 +63,23 @@ export class VocabularyDataService extends IdentifiableDataService<Vocabulary> i
5763
public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<PaginatedList<Vocabulary>>> {
5864
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
5965
}
66+
67+
/**
68+
* Return the controlled vocabulary configured for the specified metadata and collection if any (/submission/vocabularies/search/{@link searchByMetadataAndCollectionPath}?metadata=<>&collection=<>)
69+
* @param metadataField metadata field to search
70+
* @param collectionUUID collection UUID where is configured the vocabulary
71+
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
72+
* no valid cached version. Defaults to true
73+
* @param reRequestOnStale Whether or not the request should automatically be re-
74+
* requested after the response becomes stale
75+
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
76+
* {@link HALLink}s should be automatically resolved
77+
*/
78+
public getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
79+
const findListOptions = new FindListOptions();
80+
findListOptions.searchParams = [new RequestParam('metadata', encodeURIComponent(metadataField)),
81+
new RequestParam('collection', encodeURIComponent(collectionUUID))];
82+
const href$ = this.searchData.getSearchByHref(this.searchByMetadataAndCollectionPath, findListOptions, ...linksToFollow);
83+
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
84+
}
6085
}

src/app/core/submission/vocabularies/vocabulary.service.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,9 @@ describe('VocabularyService', () => {
255255
spyOn((service as any).vocabularyDataService, 'findById').and.callThrough();
256256
spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough();
257257
spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough();
258+
spyOn((service as any).vocabularyDataService, 'getVocabularyByMetadataAndCollection').and.callThrough();
258259
spyOn((service as any).vocabularyDataService.findAllData, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL));
260+
spyOn((service as any).vocabularyDataService.searchData, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL));
259261
});
260262

261263
afterEach(() => {
@@ -312,6 +314,23 @@ describe('VocabularyService', () => {
312314
expect(result).toBeObservable(expected);
313315
});
314316
});
317+
318+
describe('getVocabularyByMetadataAndCollection', () => {
319+
it('should proxy the call to vocabularyDataService.getVocabularyByMetadataAndCollection', () => {
320+
scheduler.schedule(() => service.getVocabularyByMetadataAndCollection(metadata, collectionUUID));
321+
scheduler.flush();
322+
323+
expect((service as any).vocabularyDataService.getVocabularyByMetadataAndCollection).toHaveBeenCalledWith(metadata, collectionUUID, true, true);
324+
});
325+
326+
it('should return a RemoteData<Vocabulary> for the object with the given metadata and collection', () => {
327+
const result = service.getVocabularyByMetadataAndCollection(metadata, collectionUUID);
328+
const expected = cold('a|', {
329+
a: vocabularyRD
330+
});
331+
expect(result).toBeObservable(expected);
332+
});
333+
});
315334
});
316335

317336
describe('vocabulary entries', () => {

src/app/core/submission/vocabularies/vocabulary.service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ export class VocabularyService {
8787
return this.vocabularyDataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
8888
}
8989

90+
/**
91+
* Return the controlled vocabulary configured for the specified metadata and collection if any
92+
* @param metadataField metadata field to search
93+
* @param collectionUUID collection UUID where is configured the vocabulary
94+
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
95+
* no valid cached version. Defaults to true
96+
* @param reRequestOnStale Whether or not the request should automatically be re-
97+
* requested after the response becomes stale
98+
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
99+
* {@link HALLink}s should be automatically resolved
100+
* @return {Observable<RemoteData<Vocabulary>>}
101+
* Return an observable that emits vocabulary object
102+
*/
103+
getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
104+
return this.vocabularyDataService.getVocabularyByMetadataAndCollection(metadataField, collectionUUID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
105+
}
106+
90107
/**
91108
* Return the {@link VocabularyEntry} list for a given {@link Vocabulary}
92109
*

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<ds-dso-edit-metadata-value *ngFor="let mdValue of form.fields[mdField]; let idx = index" role="presentation"
44
[dso]="dso"
55
[mdValue]="mdValue"
6+
[mdField]="mdField"
67
[dsoType]="dsoType"
78
[saving$]="saving$"
89
[isOnlyValue]="form.fields[mdField].length === 1"

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ export class DsoEditMetadataValue {
7575
confirmChanges(finishEditing = false) {
7676
this.reordered = this.originalValue.place !== this.newValue.place;
7777
if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) {
78-
if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) {
78+
if (this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language
79+
|| this.originalValue.authority !== this.newValue.authority || this.originalValue.confidence !== this.newValue.confidence) {
7980
this.change = DsoEditMetadataChangeType.UPDATE;
8081
} else {
8182
this.change = undefined;
@@ -404,10 +405,13 @@ export class DsoEditMetadataForm {
404405
if (hasValue(value.change)) {
405406
if (value.change === DsoEditMetadataChangeType.UPDATE) {
406407
// Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below.
407-
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language) {
408+
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language
409+
|| value.originalValue.authority !== value.newValue.authority || value.originalValue.confidence !== value.newValue.confidence) {
408410
replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
409411
value: value.newValue.value,
410412
language: value.newValue.language,
413+
authority: value.newValue.authority,
414+
confidence: value.newValue.confidence
411415
}));
412416
}
413417
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
@@ -416,6 +420,8 @@ export class DsoEditMetadataForm {
416420
addOperations.push(new MetadataPatchAddOperation(field, {
417421
value: value.newValue.value,
418422
language: value.newValue.language,
423+
authority: value.newValue.authority,
424+
confidence: value.newValue.confidence
419425
}));
420426
} else {
421427
console.warn('Illegal metadata change state detected for', value);

src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,58 @@
11
<div class="d-flex flex-row ds-value-row" *ngVar="mdValue.newValue.isVirtual as isVirtual" role="row"
22
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
33
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
4-
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex align-items-center" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
4+
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
55
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div>
6-
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation" [(ngModel)]="mdValue.newValue.value"
6+
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation && !(isAuthorityControlled() | async)" [(ngModel)]="mdValue.newValue.value"
77
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
88
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
9+
<ds-dynamic-scrollable-dropdown *ngIf="mdValue.editing && (isScrollableVocabulary() | async)"
10+
[bindId]="mdField"
11+
[group]="group"
12+
[model]="getModel() | async"
13+
(change)="onChangeAuthorityField($event)">
14+
</ds-dynamic-scrollable-dropdown>
15+
<ds-dynamic-onebox *ngIf="mdValue.editing && ((isHierarchicalVocabulary() | async) || (isSuggesterVocabulary() | async))"
16+
[group]="group"
17+
[model]="getModel() | async"
18+
(change)="onChangeAuthorityField($event)">
19+
</ds-dynamic-onebox>
20+
<div *ngIf="!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE">
21+
<span class="badge badge-light border" >
22+
<i dsAuthorityConfidenceState
23+
class="fas fa-fw p-0"
24+
aria-hidden="true"
25+
[authorityValue]="mdValue.newValue"
26+
[iconMode]="true"
27+
></i>
28+
{{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }}
29+
</span>
30+
</div>
31+
<div class="mt-2" *ngIf=" mdValue.editing && (isAuthorityControlled() | async) && (isSuggesterVocabulary() | async)">
32+
<div class="btn-group w-75">
33+
<i dsAuthorityConfidenceState
34+
class="fas fa-fw p-0 mr-1 mt-auto mb-auto"
35+
aria-hidden="true"
36+
[authorityValue]="mdValue.newValue.confidence"
37+
[iconMode]="true"
38+
></i>
39+
<input class="form-control form-outline" [(ngModel)]="mdValue.newValue.authority" [disabled]="!editingAuthority"
40+
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
41+
(change)="onChangeAuthorityKey()" />
42+
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="!editingAuthority"
43+
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
44+
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
45+
(click)="onChangeEditingAuthorityStatus(true)">
46+
<i class="fas fa-lock fa-fw"></i>
47+
</button>
48+
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="editingAuthority"
49+
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
50+
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
51+
(click)="onChangeEditingAuthorityStatus(false)">
52+
<i class="fas fa-lock-open fa-fw"></i>
53+
</button>
54+
</div>
55+
</div>
956
<div class="d-flex" *ngIf="mdRepresentation">
1057
<a class="mr-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
1158
<ds-themed-type-badge [object]="mdRepresentation"></ds-themed-type-badge>
@@ -45,14 +92,14 @@
4592
[disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()">
4693
<i class="fas fa-undo-alt fa-fw"></i>
4794
</button>
95+
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
96+
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
97+
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
98+
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
99+
<i class="fas fa-grip-vertical fa-fw"></i>
100+
</button>
48101
</div>
49102
</div>
50-
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
51-
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
52-
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
53-
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
54-
<i class="fas fa-grip-vertical fa-fw"></i>
55-
</button>
56103
</div>
57104
</div>
58105
</div>

0 commit comments

Comments
 (0)