Merge pull request #860 from atmire/w2p-71894_metadatafields-byFieldName-search-endpoint

Item edit page metadatafields suggestion & validation
This commit is contained in:
Tim Donohue
2020-09-23 16:31:38 -05:00
committed by GitHub
16 changed files with 320 additions and 140 deletions

View File

@@ -123,7 +123,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
/**
* Check if the current page is entirely valid
*/
protected isValid() {
public isValid() {
return this.objectUpdatesService.isValidPage(this.url);
}

View File

@@ -6,13 +6,14 @@
<div *ngIf="(editable | async)" class="field-container">
<ds-filter-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key"
[url]="this.url"
[metadata]="this.metadata"
(submitSuggestion)="update(suggestionControl)"
(clickSuggestion)="update(suggestionControl)"
(typeSuggestion)="update(suggestionControl)"
(dsClickOutside)="checkValidity(suggestionControl)"
(findSuggestions)="findMetadataFieldSuggestions($event)"
#suggestionControl="ngModel"
[dsInListValidator]="metadataFields"
[valid]="(valid | async) !== false"
dsAutoFocus autoFocusSelector=".suggestion_input"
[ngModelOptions]="{standalone: true}"
@@ -46,12 +47,12 @@
</td>
<td class="text-center">
<div class="btn-group edit-field">
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
(click)="setEditable(true)" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.metadata.edit.buttons.edit' | translate}}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button [disabled]="!(canSetUneditable() | async)" *ngIf="(editable | async)"
<button [disabled]="!(canSetUneditable() | async) || (valid | async) === false" *ngIf="(editable | async)"
(click)="setEditable(false)" class="btn btn-outline-success btn-sm"
title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}">
<i class="fas fa-check fa-fw"></i>

View File

@@ -1,11 +1,12 @@
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { async, ComponentFixture, fakeAsync, TestBed, tick } 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 { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
@@ -14,9 +15,14 @@ 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 { SharedModule } from '../../../../shared/shared.module';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
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 { FilterInputSuggestionsComponent } from '../../../../shared/input-suggestions/filter-suggestions/filter-input-suggestions.component';
import { MockComponent, MockDirective } from 'ng-mocks';
import { DebounceDirective } from '../../../../shared/utils/debounce.directive';
let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>;
@@ -25,17 +31,21 @@ let el: HTMLElement;
let metadataFieldService;
let objectUpdatesService;
let paginatedMetadataFields;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' })
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdSchemaRD$ = createSuccessfulRemoteDataObject$(mdSchema);
const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchema,
schema: mdSchemaRD$,
element: 'contributor',
qualifier: 'author'
});
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
const mdField2 = Object.assign(new MetadataField(), {
schema: mdSchemaRD$,
element: 'title'
});
const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchema,
schema: mdSchemaRD$,
element: 'description',
qualifier: 'abstract'
qualifier: 'abstract',
});
const metadatum = Object.assign(new MetadatumViewModel(), {
@@ -74,11 +84,16 @@ describe('EditInPlaceFieldComponent', () => {
);
TestBed.configureTestingModule({
imports: [FormsModule, SharedModule, TranslateModule.forRoot()],
declarations: [EditInPlaceFieldComponent],
imports: [FormsModule, TranslateModule.forRoot()],
declarations: [
EditInPlaceFieldComponent,
MockDirective(DebounceDirective),
MockComponent(FilterInputSuggestionsComponent)
],
providers: [
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: MetadataFieldDataService, useValue: {} }
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
@@ -94,13 +109,12 @@ describe('EditInPlaceFieldComponent', () => {
comp.url = url;
comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum;
fixture.detectChanges();
});
describe('update', () => {
beforeEach(() => {
comp.update();
fixture.detectChanges();
});
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -112,6 +126,7 @@ describe('EditInPlaceFieldComponent', () => {
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', () => {
@@ -121,7 +136,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should contain input fields or textareas', () => {
@@ -133,7 +148,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
@@ -145,7 +160,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is true', () => {
beforeEach(() => {
comp.valid = observableOf(true);
objectUpdatesService.isValid.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should not contain an error message', () => {
@@ -157,10 +172,10 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is false', () => {
beforeEach(() => {
comp.valid = observableOf(false);
objectUpdatesService.isValid.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
it('there should be an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBeGreaterThan(0);
@@ -170,6 +185,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('remove', () => {
beforeEach(() => {
comp.remove();
fixture.detectChanges();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -180,6 +196,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('removeChangesFromField', () => {
beforeEach(() => {
comp.removeChangesFromField();
fixture.detectChanges();
});
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
@@ -192,19 +209,19 @@ describe('EditInPlaceFieldComponent', () => {
const metadataFieldSuggestions: InputSuggestion[] =
[
{ displayValue: mdField1.toString().split('.').join('.&#8203;'), value: mdField1.toString() },
{ displayValue: mdField2.toString().split('.').join('.&#8203;'), value: mdField2.toString() },
{ displayValue: mdField3.toString().split('.').join('.&#8203;'), value: mdField3.toString() }
{ displayValue: ('dc.' + mdField1.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField1.toString()) },
{ displayValue: ('dc.' + mdField2.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField2.toString()) },
{ displayValue: ('dc.' + mdField3.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField3.toString()) }
];
beforeEach(() => {
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);
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query, null, followLink('schema'));
});
it('it should set metadataFieldSuggestions to the right value', () => {
@@ -216,7 +233,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetEditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('canSetEditable should return an observable emitting false', () => {
@@ -227,12 +245,14 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
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|)';
@@ -243,6 +263,7 @@ describe('EditInPlaceFieldComponent', () => {
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|)';
@@ -255,7 +276,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetUneditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting true', () => {
@@ -266,7 +288,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting false', () => {
@@ -278,7 +301,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits true', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
@@ -290,7 +313,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
@@ -302,7 +325,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
@@ -314,7 +337,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits false', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
@@ -372,6 +395,7 @@ describe('EditInPlaceFieldComponent', () => {
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|)';
@@ -382,6 +406,7 @@ describe('EditInPlaceFieldComponent', () => {
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|)';
@@ -394,7 +419,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
@@ -408,6 +433,7 @@ describe('EditInPlaceFieldComponent', () => {
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', () => {
@@ -419,6 +445,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
it('canUndo should return an observable emitting false', () => {

View File

@@ -1,4 +1,5 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { metadataFieldsToString } from '../../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash';
@@ -9,8 +10,8 @@ import { FieldUpdate } from '../../../../core/data/object-updates/object-updates
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NgModel } from '@angular/forms';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { MetadataField } from '../../../../core/metadata/metadata-field.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
@Component({
// tslint:disable-next-line:component-selector
@@ -32,15 +33,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
*/
@Input() url: string;
/**
* List of strings with all metadata field keys available
*/
@Input() metadataFields: string[];
/**
* The metadatum of this field
*/
metadata: MetadatumViewModel;
@Input() metadata: MetadatumViewModel;
/**
* Emits whether or not this field is currently editable
@@ -126,27 +122,34 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* Ignores fields from metadata schemas "relation" and "relationship"
* @param query The query to look for
*/
findMetadataFieldSuggestions(query: string): void {
findMetadataFieldSuggestions(query: string) {
if (isNotEmpty(query)) {
this.registryService.queryMetadataFields(query).pipe(
// getSucceededRemoteData(),
take(1),
map((data) => data.payload.page)
).subscribe(
(fields: MetadataField[]) => this.metadataFieldSuggestions.next(
fields.map((field: MetadataField) => {
return {
displayValue: field.toString().split('.').join('.&#8203;'),
value: field.toString()
};
return this.registryService.queryMetadataFields(query, null, followLink('schema')).pipe(
metadataFieldsToString(),
take(1))
.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('.&#8203;'),
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

View File

@@ -16,7 +16,7 @@
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
@@ -33,7 +33,6 @@
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
ds-edit-in-place-field
[fieldUpdate]="updateValue || {}"
[metadataFields]="metadataFields$ | async"
[url]="url"
[ngClass]="{
'table-warning': updateValue.changeType === 0,

View File

@@ -6,16 +6,14 @@ import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import { Identifiable } from '../../../core/data/object-updates/object-updates.reducer';
import { first, map, switchMap, take, tap } from 'rxjs/operators';
import { first, switchMap, tap } from 'rxjs/operators';
import { getSucceededRemoteData } 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 { RegistryService } from '../../../core/registry/registry.service';
import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models';
import { Metadata } from '../../../core/shared/metadata.utils';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { UpdateDataService } from '../../../core/data/update-data.service';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { AlertType } from '../../../shared/alert/aletr-type';
@@ -42,11 +40,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/
@Input() updateService: UpdateDataService<Item>;
/**
* Observable with a list of strings with all existing metadata field keys
*/
metadataFields$: Observable<string[]>;
constructor(
public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService,
@@ -54,7 +47,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
public notificationsService: NotificationsService,
public translateService: TranslateService,
public route: ActivatedRoute,
public metadataFieldService: RegistryService,
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
}
@@ -64,7 +56,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/
ngOnInit(): void {
super.ngOnInit();
this.metadataFields$ = this.findMetadataFields();
if (hasNoValue(this.updateService)) {
this.updateService = this.itemService;
}
@@ -130,16 +121,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
});
}
/**
* Method to request all metadata fields and convert them to a list of strings
*/
findMetadataFields(): Observable<string[]> {
return this.metadataFieldService.getAllMetadataFields().pipe(
getSucceededRemoteData(),
take(1),
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
}
/**
* Check for empty metadata UUIDs and fix them (empty UUIDs would break the object-update service)
*/

View File

@@ -1,6 +1,9 @@
import { Injectable } from '@angular/core';
import { hasValue } from '../../shared/empty.util';
import { dataService } from '../cache/builders/build-decorators';
import { DataService } from './data.service';
import { PaginatedList } from './paginated-list';
import { RemoteData } from './remote-data';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
@@ -27,6 +30,7 @@ import { RequestParam } from '../cache/models/request-param.model';
export class MetadataFieldDataService extends DataService<MetadataField> {
protected linkPath = 'metadatafields';
protected searchBySchemaLinkPath = 'bySchema';
protected searchByFieldNameLinkPath = 'byFieldName';
constructor(
protected requestService: RequestService,
@@ -53,6 +57,43 @@ export class MetadataFieldDataService extends DataService<MetadataField> {
return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow);
}
/**
* Find metadata fields with either the partial metadata field name (e.g. "dc.ti") as query or an exact match to
* at least the schema, element or qualifier
* @param schema optional; an exact match of the prefix of the metadata schema (e.g. "dc", "dcterms", "eperson")
* @param element optional; an exact match of the field's element (e.g. "contributor", "title")
* @param qualifier optional; an exact match of the field's qualifier (e.g. "author", "alternative")
* @param query optional (if any of schema, element or qualifier used) - part of the fully qualified field,
* should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”)
* @param exactName optional; the exact fully qualified field, should use the syntax schema.element.qualifier or
* schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
* if there's an exact match
* @param options The options info used to retrieve the fields
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
searchByFieldNameParams(schema: string, element: string, qualifier: string, query: string, exactName: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
const optionParams = Object.assign(new FindListOptions(), options, {
searchParams: [
new RequestParam('schema', hasValue(schema) ? schema : ''),
new RequestParam('element', hasValue(element) ? element : ''),
new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''),
new RequestParam('query', hasValue(query) ? query : ''),
new RequestParam('exactName', hasValue(exactName) ? exactName : '')
]
});
return this.searchBy(this.searchByFieldNameLinkPath, optionParams, ...linksToFollow);
}
/**
* Finds a specific metadata field by name.
* @param exactFieldName The exact fully qualified field, should use the syntax schema.element.qualifier or
* schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
* if there's an exact match, empty list if there is no exact match.
*/
findByExactFieldName(exactFieldName: string): Observable<RemoteData<PaginatedList<MetadataField>>> {
return this.searchByFieldNameParams(null, null, null, null, exactFieldName, null);
}
/**
* Clear all metadata field requests
* Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema

View File

@@ -68,8 +68,8 @@ export class MetadataField extends ListableObject implements HALResource {
schema?: Observable<RemoteData<MetadataSchema>>;
/**
* Method to print this metadata field as a string
* @param separator The separator between the schema, element and qualifier in the string
* Method to print this metadata field as a string without the schema
* @param separator The separator between element and qualifier in the string
*/
toString(separator: string = '.'): string {
let key = this.element;

View File

@@ -30,7 +30,6 @@ import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'
import { MetadataFieldDataService } from '../data/metadata-field-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RequestParam } from '../cache/models/request-param.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry;
const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema);
@@ -90,20 +89,6 @@ export class RegistryService {
return this.metadataFieldService.findBySchema(schema, options, ...linksToFollow);
}
/**
* Retrieve all existing metadata fields as a paginated list
* @param options Options to determine which page of metadata fields should be requested
* When no options are provided, all metadata fields are requested in one large page
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
* @returns an observable that emits a remote data object with a page of metadata fields
*/
// TODO this is temporarily disabled. The performance is too bad.
// It is used down the line for validation. That validation will have to be rewritten against a new rest endpoint.
// Not by downloading the list of all fields.
public getAllMetadataFields(options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<MetadataField>(null, []));
}
public editMetadataSchema(schema: MetadataSchema) {
this.store.dispatch(new MetadataRegistryEditSchemaAction(schema));
}
@@ -151,6 +136,7 @@ export class RegistryService {
public getSelectedMetadataSchemas(): Observable<MetadataSchema[]> {
return this.store.pipe(select(selectedMetadataSchemasSelector));
}
/**
* Method to start editing a metadata field, dispatches an edit field action
* @param field The field that's being edited
@@ -165,12 +151,14 @@ export class RegistryService {
public cancelEditMetadataField() {
this.store.dispatch(new MetadataRegistryCancelFieldAction());
}
/**
* Method to retrieve the metadata field that are currently being edited
*/
public getActiveMetadataField(): Observable<MetadataField> {
return this.store.pipe(select(editMetadataFieldSelector));
}
/**
* Method to select a metadata field, dispatches a select field action
* @param field The field that's being selected
@@ -178,6 +166,7 @@ export class RegistryService {
public selectMetadataField(field: MetadataField) {
this.store.dispatch(new MetadataRegistrySelectFieldAction(field));
}
/**
* Method to deselect a metadata field, dispatches a deselect field action
* @param field The field that's it being deselected
@@ -185,6 +174,7 @@ export class RegistryService {
public deselectMetadataField(field: MetadataField) {
this.store.dispatch(new MetadataRegistryDeselectFieldAction(field));
}
/**
* Method to deselect all currently selected metadata fields, dispatches a deselect all field action
*/
@@ -213,7 +203,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
this.showNotifications(true, isUpdate, false, {prefix: schema.prefix});
this.showNotifications(true, isUpdate, false, { prefix: schema.prefix });
})
);
}
@@ -244,7 +234,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
this.showNotifications(true, false, true, {field: field.toString()});
this.showNotifications(true, false, true, { field: field.toString() });
})
);
}
@@ -259,7 +249,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
this.showNotifications(true, true, true, {field: field.toString()});
this.showNotifications(true, true, true, { field: field.toString() });
})
);
}
@@ -271,6 +261,7 @@ export class RegistryService {
public deleteMetadataField(id: number): Observable<RestResponse> {
return this.metadataFieldService.delete(`${id}`);
}
/**
* Method that clears a cached metadata field request and returns its REST url
*/
@@ -297,13 +288,11 @@ export class RegistryService {
/**
* Retrieve a filtered paginated list of metadata fields
* @param query {string} The query to filter the field names by
* @param query {string} The query to use for the metadata field name, can be part of the fully qualified field,
* should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”)
* @returns an observable that emits a remote data object with a page of metadata fields that match the query
*/
// TODO this is temporarily disabled. The performance is too bad.
// Querying metadatafields will need to be implemented as a search endpoint on the rest api,
// not by downloading everything and preforming the query client side.
queryMetadataFields(query: string): Observable<RemoteData<PaginatedList<MetadataField>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<MetadataField>(null, []));
queryMetadataFields(query: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
return this.metadataFieldService.searchByFieldNameParams(null, null, null, query, null, options, ...linksToFollow);
}
}

View File

@@ -1,6 +1,6 @@
import { Router, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { filter, find, flatMap, map, take, tap } from 'rxjs/operators';
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { filter, find, flatMap, map, switchMap, take, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/search-result.model';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
@@ -9,6 +9,8 @@ import { RemoteData } from '../data/remote-data';
import { RestRequest } from '../data/request.models';
import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
import { MetadataField } from '../metadata/metadata-field.model';
import { MetadataSchema } from '../metadata/metadata-schema.model';
import { BrowseDefinition } from './browse-definition.model';
import { DSpaceObject } from './dspace-object.model';
import { getUnauthorizedRoute } from '../../app-routing-paths';
@@ -265,3 +267,27 @@ export const paginatedListToArray = () =>
hasValueOperator(),
map((objectRD: RemoteData<PaginatedList<T>>) => objectRD.payload.page.filter((object: T) => hasValue(object)))
);
/**
* Operator for turning a list of metadata fields into an array of string representing their schema.element.qualifier string
*/
export const metadataFieldsToString = () =>
(source: Observable<RemoteData<PaginatedList<MetadataField>>>): Observable<string[]> =>
source.pipe(
hasValueOperator(),
map((fieldRD: RemoteData<PaginatedList<MetadataField>>) => {
return fieldRD.payload.page.filter((object: MetadataField) => hasValue(object))
}),
switchMap((fields: MetadataField[]) => {
const fieldSchemaArray = fields.map((field: MetadataField) => {
return field.schema.pipe(
getFirstSucceededRemoteDataPayload(),
map((schema: MetadataSchema) => ({ field, schema }))
);
});
return observableCombineLatest(fieldSchemaArray);
}),
map((fieldSchemaArray: Array<{ field: MetadataField, schema: MetadataSchema }>): string[] => {
return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString())
})
);

View File

@@ -1,14 +1,15 @@
<form #form="ngForm" (ngSubmit)="onSubmit(value)"
<form [formGroup]="form" (ngSubmit)="onSubmit(value)"
[action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close();">
<input #inputField type="text" [(ngModel)]="value" [name]="name"
(dsClickOutside)="checkIfValidInput(form);close();">
<input #inputField type="text" formControlName="metadataNameField" [(ngModel)]="value" id="name" [name]="name"
class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
ng-model-options="{standalone: true}"
autocomplete="off">
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<div class="dropdown-list">
@@ -20,3 +21,4 @@
</div>
</div>
</form>

View File

@@ -3,9 +3,11 @@ import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angula
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import { MetadataFieldDataService } from '../../../core/data/metadata-field-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { FilterInputSuggestionsComponent } from './filter-input-suggestions.component';
describe('FilterInputSuggestionsComponent', () => {
@@ -14,19 +16,23 @@ describe('FilterInputSuggestionsComponent', () => {
let fixture: ComponentFixture<FilterInputSuggestionsComponent>;
let de: DebugElement;
let el: HTMLElement;
const suggestions = [{displayValue: 'suggestion uno', value: 'suggestion uno'}, {
const suggestions = [{ displayValue: 'suggestion uno', value: 'suggestion uno' }, {
displayValue: 'suggestion dos',
value: 'suggestion dos'
}, {displayValue: 'suggestion tres', value: 'suggestion tres'}];
}, { displayValue: 'suggestion tres', value: 'suggestion tres' }];
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule, ReactiveFormsModule],
declarations: [FilterInputSuggestionsComponent],
providers: [],
providers: [FormsModule,
ReactiveFormsModule,
{ provide: MetadataFieldDataService, useValue: {} },
{ provide: ObjectUpdatesService, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(FilterInputSuggestionsComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));

View File

@@ -1,5 +1,8 @@
import { Component, forwardRef, Input } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { MetadataFieldValidator } from '../../utils/metadatafield-validator.directive';
import { InputSuggestionsComponent } from '../input-suggestions.component';
import { InputSuggestion } from '../input-suggestions.model';
@@ -21,12 +24,39 @@ import { InputSuggestion } from '../input-suggestions.model';
/**
* Component representing a form with a autocomplete functionality
*/
export class FilterInputSuggestionsComponent extends InputSuggestionsComponent {
export class FilterInputSuggestionsComponent extends InputSuggestionsComponent implements OnInit {
form: FormGroup;
/**
* The current url of this page
*/
@Input() url: string;
/**
* The metadatum of this field
*/
@Input() metadata: MetadatumViewModel;
/**
* The suggestions that should be shown
*/
@Input() suggestions: InputSuggestion[] = [];
constructor(private metadataFieldValidator: MetadataFieldValidator,
private objectUpdatesService: ObjectUpdatesService) {
super();
}
ngOnInit() {
this.form = new FormGroup({
metadataNameField: new FormControl(this._value, {
asyncValidators: [this.metadataFieldValidator.validate.bind(this.metadataFieldValidator)],
validators: [Validators.required]
})
});
}
onSubmit(data) {
this.value = data;
this.submitSuggestion.emit(data);
@@ -41,4 +71,14 @@ export class FilterInputSuggestionsComponent extends InputSuggestionsComponent {
return false;
}
/**
* Check if the input is valid according to validator and send (in)valid state to store
* @param form Form with input
*/
checkIfValidInput(form) {
this.valid = !(form.get('metadataNameField').status === 'INVALID' && (form.get('metadataNameField').dirty || form.get('metadataNameField').touched));
this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, this.valid);
return this.valid;
}
}

View File

@@ -15,5 +15,6 @@ form {
position: relative;
.dropdown-menu {
position: absolute;
top: 40px;
}
}

View File

@@ -24,6 +24,7 @@ import { FileDropzoneNoUploaderComponent } from './file-dropzone-no-uploader/fil
import { PublicationListElementComponent } from './object-list/item-list-element/item-types/publication/publication-list-element.component';
import { EnumKeysPipe } from './utils/enum-keys-pipe';
import { FileSizePipe } from './utils/file-size-pipe';
import { MetadataFieldValidator } from './utils/metadatafield-validator.directive';
import { SafeUrlPipe } from './utils/safe-url-pipe';
import { ConsolePipe } from './utils/console.pipe';
import { CollectionListElementComponent } from './object-list/collection-list-element/collection-list-element.component';
@@ -517,7 +518,8 @@ const DIRECTIVES = [
FileValueAccessorDirective,
FileValidator,
ClaimedTaskActionsDirective,
NgForTrackByIdDirective
NgForTrackByIdDirective,
MetadataFieldValidator
];
@NgModule({

View File

@@ -0,0 +1,62 @@
import { Directive, Injectable } from '@angular/core';
import { AbstractControl, AsyncValidator, NG_VALIDATORS, ValidationErrors } from '@angular/forms';
import { map, switchMap, take } from 'rxjs/operators';
import { of as observableOf, timer as observableTimer, Observable } from 'rxjs';
import { MetadataFieldDataService } from '../../core/data/metadata-field-data.service';
import { PaginatedList } from '../../core/data/paginated-list';
import { RemoteData } from '../../core/data/remote-data';
import { MetadataField } from '../../core/metadata/metadata-field.model';
import { getSucceededRemoteData } from '../../core/shared/operators';
/**
* Directive for validating if a ngModel value is a valid metadata field
*/
@Directive({
selector: '[ngModel][dsMetadataFieldValidator]',
// We add our directive to the list of existing validators
providers: [
{ provide: NG_VALIDATORS, useExisting: MetadataFieldValidator, multi: true }
]
})
@Injectable({ providedIn: 'root' })
export class MetadataFieldValidator implements AsyncValidator {
constructor(private metadataFieldService: MetadataFieldDataService) {
}
/**
* The function that checks if the form control's value is currently valid
* @param control The FormControl
*/
validate(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
const resTimer = observableTimer(500).pipe(
switchMap(() => {
if (!control.value) {
return observableOf({ invalidMetadataField: { value: control.value } });
}
const mdFieldNameParts = control.value.split('.');
if (mdFieldNameParts.length < 2) {
return observableOf({ invalidMetadataField: { value: control.value } });
}
const res = this.metadataFieldService.findByExactFieldName(control.value)
.pipe(
getSucceededRemoteData(),
map((matchingFieldRD: RemoteData<PaginatedList<MetadataField>>) => {
if (matchingFieldRD.payload.pageInfo.totalElements === 0) {
return { invalidMetadataField: { value: control.value } };
} else if (matchingFieldRD.payload.pageInfo.totalElements === 1) {
return null;
}
})
);
res.pipe(take(1)).subscribe();
return res;
})
);
resTimer.pipe(take(1)).subscribe();
return resTimer;
}
}