diff --git a/config/config.example.yml b/config/config.example.yml index 77134d0075..898b47784f 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -164,10 +164,12 @@ browseBy: # The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) defaultLowerLimit: 1900 -# Item Page Config +# Item Config item: edit: undoTimeout: 10000 # 10 seconds + # Show the item access status label in items lists + showAccessStatuses: false # Collection Page Config collection: diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index dedada5f5f..334d69f19a 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,6 +18,8 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -31,6 +33,12 @@ describe('ItemAdminSearchResultGridElementComponent', () => { } }; + const mockAccessStatusDataService = { + findAccessStatusFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new AccessStatusObject()); + } + }; + const mockThemeService = getMockThemeService(); function init() { @@ -55,6 +63,7 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, + { provide: AccessStatusDataService, useValue: mockAccessStatusDataService }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e9e242dbc0..e248685ac3 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -137,6 +137,7 @@ import { SiteAdministratorGuard } from './data/feature-authorization/feature-aut import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { DsDynamicTypeBindRelationService } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; @@ -163,6 +164,8 @@ import { SequenceService } from './shared/sequence.service'; import { CoreState } from './core-state.model'; import { GroupDataService } from './eperson/group-data.service'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; +import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; +import { AccessStatusDataService } from './data/access-status-data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -220,6 +223,7 @@ const PROVIDERS = [ MyDSpaceResponseParsingService, ServerResponseService, BrowseService, + AccessStatusDataService, SubmissionCcLicenseDataService, SubmissionCcLicenseUrlDataService, SubmissionFormsConfigService, @@ -250,6 +254,7 @@ const PROVIDERS = [ ClaimedTaskDataService, PoolTaskDataService, BitstreamDataService, + DsDynamicTypeBindRelationService, EntityTypeService, ContentSourceResponseParsingService, ItemTemplateDataService, @@ -346,7 +351,8 @@ export const models = UsageReport, Root, SearchConfig, - SubmissionAccessesModel + SubmissionAccessesModel, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts new file mode 100644 index 0000000000..d81b9384f3 --- /dev/null +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -0,0 +1,81 @@ +import { RequestService } from './request.service'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { GetRequest } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Observable } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { hasNoValue } from '../../shared/empty.util'; +import { AccessStatusDataService } from './access-status-data.service'; +import { Item } from '../shared/item.model'; + +const url = 'fake-url'; + +describe('AccessStatusDataService', () => { + let service: AccessStatusDataService; + let requestService: RequestService; + let notificationsService: any; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; + + const itemId = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const mockItem: Item = Object.assign(new Item(), { + id: itemId, + name: 'test-item', + _links: { + accessStatus: { + href: `https://rest.api/items/${itemId}/accessStatus` + }, + self: { + href: `https://rest.api/items/${itemId}` + } + } + }); + + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); + }); + + describe('when calling findAccessStatusFor', () => { + let contentSource$; + + beforeEach(() => { + contentSource$ = service.findAccessStatusFor(mockItem); + }); + + it('should send a new GetRequest', fakeAsync(() => { + contentSource$.subscribe(); + tick(); + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), true); + })); + }); + }); + + /** + * Create an AccessStatusDataService used for testing + * @param reponse$ Supply a RemoteData to be returned by the REST API (optional) + */ + function createService(reponse$?: Observable>) { + requestService = getMockRequestService(); + let buildResponse$ = reponse$; + if (hasNoValue(reponse$)) { + buildResponse$ = createSuccessfulRemoteDataObject$({}); + } + rdbService = jasmine.createSpyObj('rdbService', { + buildFromRequestUUID: buildResponse$, + buildSingle: buildResponse$ + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + service = new AccessStatusDataService(null, halService, null, notificationsService, objectCache, rdbService, requestService, null); + } +}); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts new file mode 100644 index 0000000000..09843fac9b --- /dev/null +++ b/src/app/core/data/access-status-data.service.ts @@ -0,0 +1,45 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { CoreState } from '../core-state.model'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { Item } from '../shared/item.model'; + +@Injectable() +@dataService(ACCESS_STATUS) +export class AccessStatusDataService extends DataService { + + protected linkPath = 'accessStatus'; + + constructor( + protected comparator: DefaultChangeAnalyzer, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected objectCache: ObjectCacheService, + protected rdbService: RemoteDataBuildService, + protected requestService: RequestService, + protected store: Store, + ) { + super(); + } + + /** + * Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item + * @param item Item we want the access status of + */ + findAccessStatusFor(item: Item): Observable> { + return this.findByHref(item._links.accessStatus.href); + } +} diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index cc1e3b6e20..a4ed9f882f 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -10,12 +10,13 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, PostRequest } from './request.models'; +import { DeleteRequest, GetRequest, PostRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { CoreState } from '../core-state.model'; import { RequestEntry } from './request-entry.model'; import { FindListOptions } from './find-list-options.model'; +import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-service.stub'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -36,13 +37,11 @@ describe('ItemDataService', () => { }) as RequestService; const rdbService = getMockRemoteDataBuildService(); - const itemEndpoint = 'https://rest.api/core/items'; + const itemEndpoint = 'https://rest.api/core'; const store = {} as Store; const objectCache = {} as ObjectCacheService; - const halEndpointService = jasmine.createSpyObj('halService', { - getEndpoint: observableOf(itemEndpoint) - }); + const halEndpointService: any = new HALEndpointServiceStub(itemEndpoint); const bundleService = jasmine.createSpyObj('bundleService', { findByHref: {} }); diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts index 207093b4d5..26ed370026 100644 --- a/src/app/core/data/version-history-data.service.spec.ts +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -151,7 +151,7 @@ describe('VersionHistoryDataService', () => { describe('when getVersionsEndpoint is called', () => { it('should return the correct value', () => { service.getVersionsEndpoint(versionHistoryId).subscribe((res) => { - expect(res).toBe(url + '/versions'); + expect(res).toBe(url + '/versionhistories/version-history-id/versions'); }); }); }); diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index d98c22225e..49ca7750b4 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -21,6 +21,8 @@ import { Version } from './version.model'; import { VERSION } from './version.resource-type'; import { BITSTREAM } from './bitstream.resource-type'; import { Bitstream } from './bitstream.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; /** * Class representing a DSpace Item @@ -72,6 +74,7 @@ export class Item extends DSpaceObject implements ChildHALResource { templateItemOf: HALLink; version: HALLink; thumbnail: HALLink; + accessStatus: HALLink; self: HALLink; }; @@ -110,6 +113,13 @@ export class Item extends DSpaceObject implements ChildHALResource { @link(BITSTREAM, false, 'thumbnail') thumbnail?: Observable>; + /** + * The access status for this Item + * Will be undefined unless the access status {@link HALLink} has been resolved. + */ + @link(ACCESS_STATUS) + accessStatus?: Observable>; + /** * Method that returns as which type of object this object should be rendered */ diff --git a/src/app/shared/empty.util.spec.ts b/src/app/shared/empty.util.spec.ts index 1112883c2a..94122990ae 100644 --- a/src/app/shared/empty.util.spec.ts +++ b/src/app/shared/empty.util.spec.ts @@ -9,7 +9,7 @@ import { isNotEmptyOperator, isNotNull, isNotUndefined, - isNull, + isNull, isObjectEmpty, isUndefined } from './empty.util'; @@ -444,6 +444,43 @@ describe('Empty Utils', () => { }); }); + describe('isObjectEmpty', () => { + /* + isObjectEmpty(); // true + isObjectEmpty(null); // true + isObjectEmpty(undefined); // true + isObjectEmpty(''); // true + isObjectEmpty([]); // true + isObjectEmpty({}); // true + isObjectEmpty({name: null}); // true + isObjectEmpty({ name: 'Adam Hawkins', surname : null}); // false + */ + it('should be empty if no parameter passed', () => { + expect(isObjectEmpty()).toBeTrue(); + }); + it('should be empty if null parameter passed', () => { + expect(isObjectEmpty(null)).toBeTrue(); + }); + it('should be empty if undefined parameter passed', () => { + expect(isObjectEmpty(undefined)).toBeTrue(); + }); + it('should be empty if empty string passed', () => { + expect(isObjectEmpty('')).toBeTrue(); + }); + it('should be empty if empty array passed', () => { + expect(isObjectEmpty([])).toBeTrue(); + }); + it('should be empty if empty object passed', () => { + expect(isObjectEmpty({})).toBeTrue(); + }); + it('should be empty if single key with null value passed', () => { + expect(isObjectEmpty({ name: null })).toBeTrue(); + }); + it('should NOT be empty if object with at least one non-null value passed', () => { + expect(isObjectEmpty({ name: 'Adam Hawkins', surname : null })).toBeFalse(); + }); + }); + describe('ensureArrayHasValue', () => { it('should let all arrays pass unchanged, and turn everything else in to empty arrays', () => { const sourceData = { diff --git a/src/app/shared/empty.util.ts b/src/app/shared/empty.util.ts index d79c520fda..355314550a 100644 --- a/src/app/shared/empty.util.ts +++ b/src/app/shared/empty.util.ts @@ -177,3 +177,29 @@ export const isNotEmptyOperator = () => export const ensureArrayHasValue = () => (source: Observable): Observable => source.pipe(map((arr: T[]): T[] => Array.isArray(arr) ? arr : [])); + +/** + * Verifies that a object keys are all empty or not. + * isObjectEmpty(); // true + * isObjectEmpty(null); // true + * isObjectEmpty(undefined); // true + * isObjectEmpty(''); // true + * isObjectEmpty([]); // true + * isObjectEmpty({}); // true + * isObjectEmpty({name: null}); // true + * isObjectEmpty({ name: 'Adam Hawkins', surname : null}); // false + */ +export function isObjectEmpty(obj?: any): boolean { + + if (typeof(obj) !== 'object') { + return true; + } + + for (const key in obj) { + if (obj.hasOwnProperty(key) && isNotEmpty(obj[key])) { + return false; + } + } + return true; +} + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 55e354ea7a..7eef1d8655 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,4 +1,5 @@
- +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index b67e6f9e46..ffc4df244e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -25,7 +25,7 @@ import { DynamicSliderModel, DynamicSwitchModel, DynamicTextAreaModel, - DynamicTimePickerModel + DynamicTimePickerModel, MATCH_VISIBLE, OR_OPERATOR } from '@ng-dynamic-forms/core'; import { DynamicNGBootstrapCalendarComponent, @@ -65,6 +65,7 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicFormGroupComponent } from './models/form-group/dynamic-form-group.component'; import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service'; import { RelationshipService } from '../../../../core/data/relationship.service'; import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; import { ItemDataService } from '../../../../core/data/item-data.service'; @@ -79,6 +80,14 @@ import { SubmissionService } from '../../../../submission/submission.service'; import { FormBuilderService } from '../form-builder.service'; import { NgxMaskModule } from 'ngx-mask'; +function getMockDsDynamicTypeBindRelationService(): DsDynamicTypeBindRelationService { + return jasmine.createSpyObj('DsDynamicTypeBindRelationService', { + getRelatedFormModel: jasmine.createSpy('getRelatedFormModel'), + matchesCondition: jasmine.createSpy('matchesCondition'), + subscribeRelations: jasmine.createSpy('subscribeRelations') + }); +} + describe('DsDynamicFormControlContainerComponent test suite', () => { const vocabularyOptions: VocabularyOptions = { @@ -111,7 +120,12 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { metadataFields: [], repeatable: false, submissionId: '1234', - hasSelectableMetadata: false + hasSelectableMetadata: false, + typeBindRelations: [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: [{id: 'dc.type', value: 'Book'}] + }] }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', @@ -200,6 +214,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { providers: [ DsDynamicFormControlContainerComponent, DynamicFormService, + { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, { provide: RelationshipService, useValue: {} }, { provide: SelectableListService, useValue: {} }, { provide: ItemDataService, useValue: {} }, @@ -231,7 +246,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { }); })); - beforeEach(inject([DynamicFormService], (service: DynamicFormService) => { + beforeEach(inject([DynamicFormService, FormBuilderService], (service: DynamicFormService, formBuilderService: FormBuilderService) => { formGroup = service.createFormGroup(formModel); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index c3359fd65a..25f650ea7e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -81,6 +81,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/ import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component'; import { find, map, startWith, switchMap, take } from 'rxjs/operators'; import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service'; import { SearchResult } from '../../../search/models/search-result.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; @@ -194,8 +195,10 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; // eslint-disable-next-line @angular-eslint/no-input-rename @Input('templates') inputTemplateList: QueryList; - + @Input() hasMetadataModel: any; @Input() formId: string; + @Input() formGroup: FormGroup; + @Input() formModel: DynamicFormControlModel[]; @Input() asBootstrapFormGroup = false; @Input() bindId = true; @Input() context: any | null = null; @@ -237,6 +240,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo protected dynamicFormComponentService: DynamicFormComponentService, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, + protected typeBindRelationService: DsDynamicTypeBindRelationService, protected translateService: TranslateService, protected relationService: DynamicFormRelationService, private modalService: NgbModal, @@ -343,6 +347,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo if (this.model && this.model.placeholder) { this.model.placeholder = this.translateService.instant(this.model.placeholder); } + if (this.model.typeBindRelations && this.model.typeBindRelations.length > 0) { + this.subscriptions.push(...this.typeBindRelationService.subscribeRelations(this.model, this.control)); + } } } @@ -357,6 +364,22 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.showErrorMessagesPreviousStage = this.showErrorMessages; } + protected createFormControlComponent(): void { + super.createFormControlComponent(); + if (this.componentType !== null) { + let index; + + if (this.context && this.context instanceof DynamicFormArrayGroupModel) { + index = this.context.index; + } + const instance = this.dynamicFormComponentService.getFormControlRef(this.model, index); + if (instance) { + (instance as any).formModel = this.formModel; + (instance as any).formGroup = this.formGroup; + } + } + } + /** * Since Form Control Components created dynamically have 'OnPush' change detection strategy, * changes are not propagated. So use this method to force an update diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html index 2a18565178..4c1ea2dd96 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html @@ -3,6 +3,7 @@ [group]="formGroup" [hasErrorMessaging]="model.hasErrorMessages" [hidden]="model.hidden" + [class.d-none]="model.hidden" [layout]="formLayout" [model]="model" [templates]="templates" diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts new file mode 100644 index 0000000000..f8bc7ea886 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts @@ -0,0 +1,143 @@ +import {inject, TestBed} from '@angular/core/testing'; + +import { + DynamicFormControlRelation, + DynamicFormRelationService, + MATCH_VISIBLE, + OR_OPERATOR, + HIDDEN_MATCHER, + HIDDEN_MATCHER_PROVIDER, REQUIRED_MATCHER_PROVIDER, DISABLED_MATCHER_PROVIDER, +} from '@ng-dynamic-forms/core'; + +import { + mockInputWithTypeBindModel, MockRelationModel, mockDcTypeInputModel +} from '../../../mocks/form-models.mock'; +import {DsDynamicTypeBindRelationService} from './ds-dynamic-type-bind-relation.service'; +import {FormFieldMetadataValueObject} from '../models/form-field-metadata-value.model'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {FormBuilderService} from '../form-builder.service'; +import {getMockFormBuilderService} from '../../../mocks/form-builder-service.mock'; +import {Injector} from '@angular/core'; + +describe('DSDynamicTypeBindRelationService test suite', () => { + let service: DsDynamicTypeBindRelationService; + let dynamicFormRelationService: DynamicFormRelationService; + let injector: Injector; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + providers: [ + { provide: FormBuilderService, useValue: getMockFormBuilderService() }, + { provide: DsDynamicTypeBindRelationService, useClass: DsDynamicTypeBindRelationService }, + { provide: DynamicFormRelationService }, + DISABLED_MATCHER_PROVIDER, HIDDEN_MATCHER_PROVIDER, REQUIRED_MATCHER_PROVIDER + ] + }).compileComponents().then(); + }); + + beforeEach(inject([DsDynamicTypeBindRelationService, DynamicFormRelationService], + (relationService: DsDynamicTypeBindRelationService, + formRelationService: DynamicFormRelationService, + ) => { + service = relationService; + dynamicFormRelationService = formRelationService; + })); + + describe('Test getTypeBindValue method', () => { + it('Should get type bind "boundType" from the given metadata object value', () => { + const mockMetadataValueObject: FormFieldMetadataValueObject = new FormFieldMetadataValueObject( + 'boundType', null, null, 'Bound Type' + ); + const bindType = service.getTypeBindValue(mockMetadataValueObject); + expect(bindType).toBe('boundType'); + }); + it('Should get type authority key "bound-auth-key" from the given metadata object value', () => { + const mockMetadataValueObject: FormFieldMetadataValueObject = new FormFieldMetadataValueObject( + 'boundType', null, 'bound-auth-key', 'Bound Type' + ); + const bindType = service.getTypeBindValue(mockMetadataValueObject); + expect(bindType).toBe('bound-auth-key'); + }); + it('Should get passed string returned directly as string passed instead of metadata', () => { + const bindType = service.getTypeBindValue('rawString'); + expect(bindType).toBe('rawString'); + }); + it('Should get "undefined" returned directly as no object given', () => { + const bindType = service.getTypeBindValue(undefined); + expect(bindType).toBeUndefined(); + }); + }); + + describe('Test getRelatedFormModel method', () => { + it('Should get 0 related form models for simple type bind mock data', () => { + const testModel = MockRelationModel; + const relatedModels = service.getRelatedFormModel(testModel); + expect(relatedModels).toHaveSize(0); + }); + it('Should get 1 related form models for mock relation model data', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const relatedModels = service.getRelatedFormModel(testModel); + expect(relatedModels).toHaveSize(1); + }); + }); + + describe('Test matchesCondition method', () => { + it('Should receive one subscription to dc.type type binding"', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const dcTypeControl = new FormControl(); + dcTypeControl.setValue('boundType'); + let subscriptions = service.subscribeRelations(testModel, dcTypeControl); + expect(subscriptions).toHaveSize(1); + }); + + it('Expect hasMatch to be true (ie. this should be hidden)', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const dcTypeControl = new FormControl(); + dcTypeControl.setValue('boundType'); + testModel.typeBindRelations[0].when[0].value = 'anotherType'; + const relation = dynamicFormRelationService.findRelationByMatcher((testModel as any).typeBindRelations, HIDDEN_MATCHER); + const matcher = HIDDEN_MATCHER; + if (relation !== undefined) { + const hasMatch = service.matchesCondition(relation, matcher); + matcher.onChange(hasMatch, testModel, dcTypeControl, injector); + expect(hasMatch).toBeTruthy(); + } + }); + + it('Expect hasMatch to be false (ie. this should NOT be hidden)', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const dcTypeControl = new FormControl(); + dcTypeControl.setValue('boundType'); + testModel.typeBindRelations[0].when[0].value = 'boundType'; + const relation = dynamicFormRelationService.findRelationByMatcher((testModel as any).typeBindRelations, HIDDEN_MATCHER); + const matcher = HIDDEN_MATCHER; + if (relation !== undefined) { + const hasMatch = service.matchesCondition(relation, matcher); + matcher.onChange(hasMatch, testModel, dcTypeControl, injector); + expect(hasMatch).toBeFalsy(); + } + }); + + }); + +}); + +function getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: 'dc.type', + value: value + }); + }); + return [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues + }]; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts new file mode 100644 index 0000000000..5dd4a6627d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts @@ -0,0 +1,230 @@ +import { Inject, Injectable, Injector, Optional } from '@angular/core'; +import { FormControl } from '@angular/forms'; + +import { Subscription } from 'rxjs'; +import { startWith } from 'rxjs/operators'; + +import { + AND_OPERATOR, + DYNAMIC_MATCHERS, + DynamicFormControlCondition, + DynamicFormControlMatcher, + DynamicFormControlModel, + DynamicFormControlRelation, + DynamicFormRelationService, MATCH_VISIBLE, + OR_OPERATOR +} from '@ng-dynamic-forms/core'; + +import {hasNoValue, hasValue} from '../../../empty.util'; +import { FormBuilderService } from '../form-builder.service'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './ds-dynamic-form-constants'; + +/** + * Service to manage type binding for submission input fields + * Any form component with the typeBindRelations DynamicFormControlRelation property can be controlled this way + */ +@Injectable() +export class DsDynamicTypeBindRelationService { + + constructor(@Optional() @Inject(DYNAMIC_MATCHERS) private dynamicMatchers: DynamicFormControlMatcher[], + protected dynamicFormRelationService: DynamicFormRelationService, + protected formBuilderService: FormBuilderService, + protected injector: Injector) { + } + + /** + * Return the string value of the type bind model + * @param bindModelValue + * @private + */ + public getTypeBindValue(bindModelValue: string | FormFieldMetadataValueObject): string { + let value; + if (hasNoValue(bindModelValue) || typeof bindModelValue === 'string') { + value = bindModelValue; + } else if (bindModelValue instanceof FormFieldMetadataValueObject + && bindModelValue.hasAuthority()) { + value = bindModelValue.authority; + } else { + value = bindModelValue.value; + } + + return value; + } + + + /** + * Get models for this bind type + * @param model + */ + public getRelatedFormModel(model: DynamicFormControlModel): DynamicFormControlModel[] { + + const models: DynamicFormControlModel[] = []; + + (model as any).typeBindRelations.forEach((relGroup) => relGroup.when.forEach((rel) => { + + if (model.id === rel.id) { + throw new Error(`FormControl ${model.id} cannot depend on itself`); + } + + const bindModel: DynamicFormControlModel = this.formBuilderService.getTypeBindModel(); + + if (model && !models.some((modelElement) => modelElement === bindModel)) { + models.push(bindModel); + } + })); + + return models; + } + + /** + * Return false if the type bind relation (eg. {MATCH_VISIBLE, OR, ['book', 'book part']}) matches the value in + * matcher.match or true if the opposite match. Since this is called with regard to actively *hiding* a form + * component, the negation of the comparison is returned. + * @param relation type bind relation (eg. {MATCH_VISIBLE, OR, ['book', 'book part']}) + * @param matcher contains 'match' value and an onChange() event listener + */ + public matchesCondition(relation: DynamicFormControlRelation, matcher: DynamicFormControlMatcher): boolean { + + // Default to OR for operator (OR is explicitly set in field-parser.ts anyway) + const operator = relation.operator || OR_OPERATOR; + + + return relation.when.reduce((hasAlreadyMatched: boolean, condition: DynamicFormControlCondition, index: number) => { + // Get the DynamicFormControlModel (typeBindModel) from the form builder service, set in the form builder + // in the form model at init time in formBuilderService.modelFromConfiguration (called by other form components + // like relation group component and submission section form component). + // This model (DynamicRelationGroupModel) contains eg. mandatory field, formConfiguration, relationFields, + // submission scope, form/section type and other high level properties + const bindModel: any = this.formBuilderService.getTypeBindModel(); + + let values: string[]; + let bindModelValue = bindModel.value; + + // If the form type is RELATION, set bindModelValue to the mandatory field for this model, otherwise leave + // as plain value + if (bindModel.type === DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP) { + bindModelValue = bindModel.value.map((entry) => entry[bindModel.mandatoryField]); + } + // Support multiple bind models + if (Array.isArray(bindModelValue)) { + values = [...bindModelValue.map((entry) => this.getTypeBindValue(entry))]; + } else { + values = [this.getTypeBindValue(bindModelValue)]; + } + + // If bind model evaluates to 'true' (is not undefined, is not null, is not false etc, + // AND the relation match (type bind) is equal to the matcher match (item publication type), then the return + // value is initialised as false. + let returnValue = (!(bindModel && relation.match === matcher.match)); + + // Iterate the type bind values parsed and mapped from our form/relation group model + for (const value of values) { + if (bindModel && relation.match === matcher.match) { + // If we're not at the first array element, and we're using the AND operator, and we have not + // yet matched anything, return false. + if (index > 0 && operator === AND_OPERATOR && !hasAlreadyMatched) { + return false; + } + // If we're not at the first array element, and we're using the OR operator (almost always the case) + // and we've already matched then there is no need to continue, just return true. + if (index > 0 && operator === OR_OPERATOR && hasAlreadyMatched) { + return true; + } + + // Do the actual match. Does condition.value (the item publication type) match the field model + // type bind currently being inspected? + returnValue = condition.value === value; + + // If return value is already true, break. + if (returnValue) { + break; + } + } + + // Test opposingMatch (eg. if match is VISIBLE, opposingMatch will be HIDDEN) + if (bindModel && relation.match === matcher.opposingMatch) { + // If we're not at the first element, using AND, and already matched, just return true here + if (index > 0 && operator === AND_OPERATOR && hasAlreadyMatched) { + return true; + } + + // If we're not at the first element, using OR, and we have NOT already matched, return false + if (index > 0 && operator === OR_OPERATOR && !hasAlreadyMatched) { + return false; + } + + // Negated comparison for return value since this is expected to be in the context of a HIDDEN_MATCHER + returnValue = !(condition.value === value); + + // Break if already false + if (!returnValue) { + break; + } + } + } + return returnValue; + }, false); + } + + /** + * Return an array of subscriptions to a calling component + * @param model + * @param control + */ + subscribeRelations(model: DynamicFormControlModel, control: FormControl): Subscription[] { + + const relatedModels = this.getRelatedFormModel(model); + const subscriptions: Subscription[] = []; + + Object.values(relatedModels).forEach((relatedModel: any) => { + + if (hasValue(relatedModel)) { + const initValue = (hasNoValue(relatedModel.value) || typeof relatedModel.value === 'string') ? relatedModel.value : + (Array.isArray(relatedModel.value) ? relatedModel.value : relatedModel.value.value); + + const valueChanges = relatedModel.valueChanges.pipe( + startWith(initValue) + ); + + // Build up the subscriptions to watch for changes; + subscriptions.push(valueChanges.subscribe(() => { + // Iterate each matcher + if (hasValue(this.dynamicMatchers)) { + this.dynamicMatchers.forEach((matcher) => { + // Find the relation + const relation = this.dynamicFormRelationService.findRelationByMatcher((model as any).typeBindRelations, matcher); + // If the relation is defined, get matchesCondition result and pass it to the onChange event listener + if (relation !== undefined) { + const hasMatch = this.matchesCondition(relation, matcher); + matcher.onChange(hasMatch, model, control, this.injector); + } + }); + } + })); + } + }); + + return subscriptions; + } + + /** + * Helper function to construct a typeBindRelations array + * @param configuredTypeBindValues + */ + public getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: 'dc.type', + value: value + }); + }); + return [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues + }]; + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html index bc41ade088..d518d59da2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html @@ -1,6 +1,7 @@
@@ -13,7 +14,8 @@ cdkDrag cdkDragHandle [cdkDragDisabled]="dragDisabled" - [cdkDragPreviewClass]="'ds-submission-reorder-dragging'"> + [cdkDragPreviewClass]="'ds-submission-reorder-dragging'" + [class.grey-background]="model.isInlineGroupArray">
@@ -22,9 +24,11 @@
- - -
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts index 921b159718..01bba74cc8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts @@ -6,6 +6,7 @@ import { DynamicFormControlCustomEvent, DynamicFormControlEvent, DynamicFormControlLayout, + DynamicFormControlModel, DynamicFormLayout, DynamicFormLayoutService, DynamicFormValidationService, @@ -22,6 +23,8 @@ import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model'; }) export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent { + @Input() bindId = true; + @Input() formModel: DynamicFormControlModel[]; @Input() formLayout: DynamicFormLayout; @Input() group: FormGroup; @Input() layout: DynamicFormControlLayout; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index 1c053ffc80..5af9b2bd32 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -2,20 +2,29 @@ import { DynamicDateControlModel, DynamicDatePickerModelConfig, DynamicFormControlLayout, + DynamicFormControlModel, + DynamicFormControlRelation, serializable } from '@ng-dynamic-forms/core'; +import {BehaviorSubject, Subject} from 'rxjs'; +import {isEmpty, isNotUndefined} from '../../../../../empty.util'; +import {MetadataValue} from '../../../../../../core/shared/metadata.models'; export const DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER = 'DATE'; export interface DynamicDsDateControlModelConfig extends DynamicDatePickerModelConfig { legend?: string; + typeBindRelations?: DynamicFormControlRelation[]; } /** * Dynamic Date Picker Model class */ export class DynamicDsDatePickerModel extends DynamicDateControlModel { + @serializable() hiddenUpdates: Subject; + @serializable() typeBindRelations: DynamicFormControlRelation[]; @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER; + @serializable() metadataValue: MetadataValue; malformedDate: boolean; legend: string; hasLanguages = false; @@ -25,6 +34,23 @@ export class DynamicDsDatePickerModel extends DynamicDateControlModel { super(config, layout); this.malformedDate = false; this.legend = config.legend; + this.metadataValue = (config as any).metadataValue; + this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; + this.hiddenUpdates = new BehaviorSubject(this.hidden); + + // This was a subscription, then an async setTimeout, but it seems unnecessary + const parentModel = this.getRootParent(this); + if (parentModel && isNotUndefined(parentModel.hidden)) { + parentModel.hidden = this.hidden; + } + } + + private getRootParent(model: any): DynamicFormControlModel { + if (isEmpty(model) || isEmpty(model.parent)) { + return model; + } else { + return this.getRootParent(model.parent); + } } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index 290e29dc65..ed99acb34e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -1,14 +1,15 @@ import { DynamicFormControlLayout, + DynamicFormControlRelation, DynamicInputModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; -import { Subject } from 'rxjs'; +import {Subject} from 'rxjs'; import { LanguageCode } from '../../models/form-field-language-value.model'; import { VocabularyOptions } from '../../../../../core/submission/vocabularies/models/vocabulary-options.model'; -import { hasValue } from '../../../../empty.util'; +import {hasValue} from '../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; @@ -18,12 +19,14 @@ export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { language?: string; place?: number; value?: any; + typeBindRelations?: DynamicFormControlRelation[]; relationship?: RelationshipOptions; repeatable: boolean; metadataFields: string[]; submissionId: string; hasSelectableMetadata: boolean; metadataValue?: FormFieldMetadataValueObject; + isModelOfInnerForm?: boolean; } @@ -33,12 +36,17 @@ export class DsDynamicInputModel extends DynamicInputModel { @serializable() private _languageCodes: LanguageCode[]; @serializable() private _language: string; @serializable() languageUpdates: Subject; + @serializable() place: number; + @serializable() typeBindRelations: DynamicFormControlRelation[]; + @serializable() typeBindHidden = false; @serializable() relationship?: RelationshipOptions; @serializable() repeatable?: boolean; @serializable() metadataFields: string[]; @serializable() submissionId: string; @serializable() hasSelectableMetadata: boolean; @serializable() metadataValue: FormFieldMetadataValueObject; + @serializable() isModelOfInnerForm: boolean; + constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); @@ -51,6 +59,8 @@ export class DsDynamicInputModel extends DynamicInputModel { this.submissionId = config.submissionId; this.hasSelectableMetadata = config.hasSelectableMetadata; this.metadataValue = config.metadataValue; + this.place = config.place; + this.isModelOfInnerForm = (hasValue(config.isModelOfInnerForm) ? config.isModelOfInnerForm : false); this.language = config.language; if (!this.language) { @@ -71,6 +81,8 @@ export class DsDynamicInputModel extends DynamicInputModel { this.language = lang; }); + this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; + this.vocabularyOptions = config.vocabularyOptions; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts index d0b07de885..52364df45e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -1,5 +1,12 @@ -import { DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { + DynamicFormArrayModel, + DynamicFormArrayModelConfig, + DynamicFormControlLayout, + DynamicFormControlRelation, + serializable +} from '@ng-dynamic-forms/core'; import { RelationshipOptions } from '../../models/relationship-options.model'; +import { hasValue } from '../../../../empty.util'; export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig { notRepeatable: boolean; @@ -10,6 +17,9 @@ export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig metadataFields: string[]; hasSelectableMetadata: boolean; isDraggable: boolean; + showButtons: boolean; + typeBindRelations?: DynamicFormControlRelation[]; + isInlineGroupArray?: boolean; } export class DynamicRowArrayModel extends DynamicFormArrayModel { @@ -21,17 +31,29 @@ export class DynamicRowArrayModel extends DynamicFormArrayModel { @serializable() metadataFields: string[]; @serializable() hasSelectableMetadata: boolean; @serializable() isDraggable: boolean; + @serializable() showButtons = true; + @serializable() typeBindRelations: DynamicFormControlRelation[]; isRowArray = true; + isInlineGroupArray = false; constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - this.notRepeatable = config.notRepeatable; - this.required = config.required; + if (hasValue(config.notRepeatable)) { + this.notRepeatable = config.notRepeatable; + } + if (hasValue(config.required)) { + this.required = config.required; + } + if (hasValue(config.showButtons)) { + this.showButtons = config.showButtons; + } this.submissionId = config.submissionId; this.relationshipConfig = config.relationshipConfig; this.metadataKey = config.metadataKey; this.metadataFields = config.metadataFields; this.hasSelectableMetadata = config.hasSelectableMetadata; this.isDraggable = config.isDraggable; + this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; + this.isInlineGroupArray = config.isInlineGroupArray ? config.isInlineGroupArray : false; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html index 843ed95530..8e1645633e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html @@ -1,11 +1,17 @@ +
-
- - +
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts index 789d5eb87c..9d8d73eab5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts @@ -5,7 +5,9 @@ import { DynamicFormControlCustomEvent, DynamicFormControlEvent, DynamicFormControlLayout, - DynamicFormGroupModel, DynamicFormLayout, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, DynamicFormLayoutService, DynamicFormValidationService, DynamicTemplateDirective @@ -18,6 +20,7 @@ import { }) export class DsDynamicFormGroupComponent extends DynamicFormControlComponent { + @Input() formModel: DynamicFormControlModel[]; @Input() formLayout: DynamicFormLayout; @Input() group: FormGroup; @Input() layout: DynamicFormControlLayout; diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 4055c84921..ac9c2b652b 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -26,7 +26,7 @@ import { DynamicSliderModel, DynamicSwitchModel, DynamicTextAreaModel, - DynamicTimePickerModel + DynamicTimePickerModel, } from '@ng-dynamic-forms/core'; import { DynamicTagModel } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; import { DynamicListCheckboxGroupModel } from './ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model'; @@ -48,12 +48,18 @@ import { DynamicConcatModel } from './ds-dynamic-form-ui/models/ds-dynamic-conca import { DynamicLookupNameModel } from './ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model'; import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; +import {ConfigurationDataService} from '../../../core/data/configuration-data.service'; +import {createSuccessfulRemoteDataObject$} from '../../remote-data.utils'; +import {ConfigurationProperty} from '../../../core/shared/configuration-property.model'; describe('FormBuilderService test suite', () => { let testModel: DynamicFormControlModel[]; let testFormConfiguration: SubmissionFormsModel; let service: FormBuilderService; + let configSpy: ConfigurationDataService; + const typeFieldProp = 'submit.type-bind.field'; + const typeFieldTestValue = 'dc.type'; const submissionId = '1234'; @@ -65,15 +71,24 @@ describe('FormBuilderService test suite', () => { return new Promise((resolve) => setTimeout(() => resolve(true), 0)); } - beforeEach(() => { + const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: typeFieldProp, + values: values, + }), + }); + beforeEach(() => { + configSpy = createConfigSuccessSpy(typeFieldTestValue); TestBed.configureTestingModule({ imports: [ReactiveFormsModule], providers: [ { provide: FormBuilderService, useClass: FormBuilderService }, { provide: DynamicFormValidationService, useValue: {} }, { provide: NG_VALIDATORS, useValue: testValidator, multi: true }, - { provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true } + { provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true }, + { provide: ConfigurationDataService, useValue: configSpy } ] }); @@ -197,7 +212,7 @@ describe('FormBuilderService test suite', () => { repeatable: false, metadataFields: [], submissionId: '1234', - hasSelectableMetadata: false + hasSelectableMetadata: false, }), new DynamicScrollableDropdownModel({ @@ -233,6 +248,7 @@ describe('FormBuilderService test suite', () => { hints: 'Enter the name of the author.', input: { type: 'onebox' }, label: 'Authors', + typeBind: [], languageCodes: [], mandatory: 'true', mandatoryMessage: 'Required field!', @@ -304,7 +320,9 @@ describe('FormBuilderService test suite', () => { required: false, metadataKey: 'dc.contributor.author', metadataFields: ['dc.contributor.author'], - hasSelectableMetadata: true + hasSelectableMetadata: true, + showButtons: true, + typeBindRelations: [{ match: 'VISIBLE', operator: 'OR', when: [{id: 'dc.type', value: 'Book' }]}] }, ), ]; @@ -424,7 +442,9 @@ describe('FormBuilderService test suite', () => { } as any; }); - beforeEach(inject([FormBuilderService], (formService: FormBuilderService) => service = formService)); + beforeEach(inject([FormBuilderService], (formService: FormBuilderService) => { + service = formService; + })); it('should find a dynamic form control model by id', () => { @@ -875,4 +895,12 @@ describe('FormBuilderService test suite', () => { expect(formArray.length === 0).toBe(true); }); + + it(`should request the ${typeFieldProp} property and set value "dc_type"`, () => { + const typeValue = service.getTypeField(); + expect(configSpy.findByPropertyName).toHaveBeenCalledTimes(1); + expect(configSpy.findByPropertyName).toHaveBeenCalledWith(typeFieldProp); + expect(typeValue).toEqual('dc_type'); + }); + }); diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index 85d70f20dc..93a398a59d 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@angular/core'; -import { AbstractControl, FormGroup } from '@angular/forms'; +import {Injectable, Optional} from '@angular/core'; +import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, @@ -7,6 +7,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_GROUP, DYNAMIC_FORM_CONTROL_TYPE_INPUT, DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP, + DynamicFormArrayGroupModel, DynamicFormArrayModel, DynamicFormComponentService, DynamicFormControlEvent, @@ -19,7 +20,15 @@ import { } from '@ng-dynamic-forms/core'; import { isObject, isString, mergeWith } from 'lodash'; -import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined, isNull } from '../../empty.util'; +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, + isNotNull, + isNotUndefined, + isNull +} from '../../empty.util'; import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; @@ -32,16 +41,61 @@ import { dateToString, isNgbDateStruct } from '../../date.util'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './ds-dynamic-form-ui/ds-dynamic-form-constants'; import { CONCAT_GROUP_SUFFIX, DynamicConcatModel } from './ds-dynamic-form-ui/models/ds-dynamic-concat.model'; import { VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models'; +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; @Injectable() export class FormBuilderService extends DynamicFormService { + private typeBindModel: DynamicFormControlModel; + + /** + * This map contains the active forms model + */ + private formModels: Map; + + /** + * This map contains the active forms control groups + */ + private formGroups: Map; + + /** + * This is the field to use for type binding + */ + private typeField: string; + constructor( componentService: DynamicFormComponentService, validationService: DynamicFormValidationService, - protected rowParser: RowParser + protected rowParser: RowParser, + @Optional() protected configService: ConfigurationDataService, ) { super(componentService, validationService); + this.formModels = new Map(); + this.formGroups = new Map(); + // If optional config service was passed, perform an initial set of type field (default dc_type) for type binds + if (hasValue(this.configService)) { + this.setTypeBindFieldFromConfig(); + } + + + } + + createDynamicFormControlEvent(control: FormControl, group: FormGroup, model: DynamicFormControlModel, type: string): DynamicFormControlEvent { + const $event = { + value: (model as any).value, + autoSave: false + }; + const context: DynamicFormArrayGroupModel = (model?.parent instanceof DynamicFormArrayGroupModel) ? model?.parent : null; + return {$event, context, control: control, group: group, model: model, type}; + } + + getTypeBindModel() { + return this.typeBindModel; + } + + setTypeBindModel(model: DynamicFormControlModel) { + this.typeBindModel = model; } findById(id: string, groupModel: DynamicFormControlModel[], arrayIndex = null): DynamicFormControlModel | null { @@ -223,13 +277,15 @@ export class FormBuilderService extends DynamicFormService { return result; } - modelFromConfiguration(submissionId: string, json: string | SubmissionFormsModel, scopeUUID: string, sectionData: any = {}, submissionScope?: string, readOnly = false): DynamicFormControlModel[] | never { - let rows: DynamicFormControlModel[] = []; - const rawData = typeof json === 'string' ? JSON.parse(json, parseReviver) : json; - + modelFromConfiguration(submissionId: string, json: string | SubmissionFormsModel, scopeUUID: string, sectionData: any = {}, + submissionScope?: string, readOnly = false, typeBindModel = null, + isInnerForm = false): DynamicFormControlModel[] | never { + let rows: DynamicFormControlModel[] = []; + const rawData = typeof json === 'string' ? JSON.parse(json, parseReviver) : json; if (rawData.rows && !isEmpty(rawData.rows)) { rawData.rows.forEach((currentRow) => { - const rowParsed = this.rowParser.parse(submissionId, currentRow, scopeUUID, sectionData, submissionScope, readOnly); + const rowParsed = this.rowParser.parse(submissionId, currentRow, scopeUUID, sectionData, submissionScope, + readOnly, this.getTypeField()); if (isNotNull(rowParsed)) { if (Array.isArray(rowParsed)) { rows = rows.concat(rowParsed); @@ -240,6 +296,13 @@ export class FormBuilderService extends DynamicFormService { }); } + if (hasNoValue(typeBindModel)) { + typeBindModel = this.findById(this.typeField, rows); + } + + if (hasValue(typeBindModel)) { + this.setTypeBindModel(typeBindModel); + } return rows; } @@ -309,6 +372,10 @@ export class FormBuilderService extends DynamicFormService { return isNotEmpty(fieldModel) ? formGroup.get(this.getPath(fieldModel)) : null; } + getFormControlByModel(formGroup: FormGroup, fieldModel: DynamicFormControlModel): AbstractControl { + return isNotEmpty(fieldModel) ? formGroup.get(this.getPath(fieldModel)) : null; + } + /** * Note (discovered while debugging) this is not the ID as used in the form, * but the first part of the path needed in a patch operation: @@ -328,6 +395,35 @@ export class FormBuilderService extends DynamicFormService { return (tempModel.id !== tempModel.name) ? tempModel.name : tempModel.id; } + /** + * If present, remove form model from formModels map + * @param id id of model + */ + removeFormModel(id: string): void { + if (this.formModels.has(id)) { + this.formModels.delete(id); + } + } + + /** + * Add new form model to formModels map + * @param id id of model + * @param formGroup FormGroup + */ + addFormGroups(id: string, formGroup: FormGroup): void { + this.formGroups.set(id, formGroup); + } + + /** + * If present, remove form model from formModels map + * @param id id of model + */ + removeFormGroup(id: string): void { + if (this.formGroups.has(id)) { + this.formGroups.delete(id); + } + } + /** * Calculate the metadata list related to the event. * @param event @@ -400,4 +496,39 @@ export class FormBuilderService extends DynamicFormService { return Object.keys(result); } + /** + * Get the type bind field from config + */ + setTypeBindFieldFromConfig(): void { + this.configService.findByPropertyName('submit.type-bind.field').pipe( + getFirstCompletedRemoteData(), + ).subscribe((remoteData: any) => { + // make sure we got a success response from the backend + if (!remoteData.hasSucceeded) { + this.typeField = 'dc_type'; + return; + } + // Read type bind value from response and set if non-empty + const typeFieldConfig = remoteData.payload.values[0]; + if (isEmpty(typeFieldConfig)) { + this.typeField = 'dc_type'; + } else { + this.typeField = typeFieldConfig.replace(/\./g, '_'); + } + }); + } + + /** + * Get type field. If the type isn't already set, and a ConfigurationDataService is provided, set (with subscribe) + * from back end. Otherwise, get/set a default "dc_type" value + */ + getTypeField(): string { + if (hasValue(this.configService) && hasNoValue(this.typeField)) { + this.setTypeBindFieldFromConfig(); + } else if (hasNoValue(this.typeField)) { + this.typeField = 'dc_type'; + } + return this.typeField; + } + } diff --git a/src/app/shared/form/builder/models/form-field.model.ts b/src/app/shared/form/builder/models/form-field.model.ts index 95ee980aeb..be3150bae3 100644 --- a/src/app/shared/form/builder/models/form-field.model.ts +++ b/src/app/shared/form/builder/models/form-field.model.ts @@ -113,6 +113,12 @@ export class FormFieldModel { @autoserialize style: string; + /** + * Containing types to bind for this field + */ + @autoserialize + typeBind: string[]; + /** * Containing the value for this metadata field */ diff --git a/src/app/shared/form/builder/parsers/concat-field-parser.ts b/src/app/shared/form/builder/parsers/concat-field-parser.ts index 8085924422..2de5f256dc 100644 --- a/src/app/shared/form/builder/parsers/concat-field-parser.ts +++ b/src/app/shared/form/builder/parsers/concat-field-parser.ts @@ -1,4 +1,4 @@ -import { Inject } from '@angular/core'; +import {Inject} from '@angular/core'; import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { diff --git a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts index b9adf3ed65..9ab43709ad 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('DateFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/date-field-parser.ts b/src/app/shared/form/builder/parsers/date-field-parser.ts index aef0219579..c67c2c7695 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.ts @@ -1,7 +1,7 @@ import { FieldParser } from './field-parser'; import { - DynamicDsDateControlModelConfig, - DynamicDsDatePickerModel + DynamicDsDatePickerModel, + DynamicDsDateControlModelConfig } from '../ds-dynamic-form-ui/models/date-picker/date-picker.model'; import { isNotEmpty } from '../../../empty.util'; import { DS_DATE_PICKER_SEPARATOR } from '../ds-dynamic-form-ui/models/date-picker/date-picker.component'; @@ -13,7 +13,7 @@ export class DateFieldParser extends FieldParser { let malformedDate = false; const inputDateModelConfig: DynamicDsDateControlModelConfig = this.initModel(null, false, true); inputDateModelConfig.legend = this.configData.label; - + inputDateModelConfig.disabled = inputDateModelConfig.readOnly; inputDateModelConfig.toggleIcon = 'fas fa-calendar'; this.setValues(inputDateModelConfig as any, fieldValue); // Init Data and validity check diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts index e3e86d7051..d69f0e48e9 100644 --- a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts @@ -11,7 +11,8 @@ describe('DisabledFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts index 82d2aeac63..3dca7558b3 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts @@ -11,7 +11,8 @@ describe('DropdownFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts index 760fc63482..3e5ec0b9da 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -1,4 +1,4 @@ -import { Inject } from '@angular/core'; +import {Inject} from '@angular/core'; import { FormFieldModel } from '../models/form-field.model'; import { CONFIG_DATA, @@ -22,7 +22,7 @@ export class DropdownFieldParser extends FieldParser { @Inject(SUBMISSION_ID) submissionId: string, @Inject(CONFIG_DATA) configData: FormFieldModel, @Inject(INIT_FORM_VALUES) initFormValues, - @Inject(PARSER_OPTIONS) parserOptions: ParserOptions + @Inject(PARSER_OPTIONS) parserOptions: ParserOptions, ) { super(submissionId, configData, initFormValues, parserOptions); } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index da304ca267..bd6820d4b3 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -1,7 +1,7 @@ -import { Inject, InjectionToken } from '@angular/core'; +import {Inject, InjectionToken} from '@angular/core'; import { uniqueId } from 'lodash'; -import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; +import {DynamicFormControlLayout, DynamicFormControlRelation, MATCH_VISIBLE, OR_OPERATOR} from '@ng-dynamic-forms/core'; import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../empty.util'; import { FormFieldModel } from '../models/form-field.model'; @@ -26,6 +26,11 @@ export const PARSER_OPTIONS: InjectionToken = new InjectionToken< export abstract class FieldParser { protected fieldId: string; + /** + * This is the field to use for type binding + * @protected + */ + protected typeField: string; constructor( @Inject(SUBMISSION_ID) protected submissionId: string, @@ -67,6 +72,8 @@ export abstract class FieldParser { metadataFields: this.getAllFieldIds(), hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), isDraggable, + typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind, + this.parserOptions.typeField) : null, groupFactory: () => { let model; if ((arrayCounter === 0)) { @@ -275,7 +282,7 @@ export abstract class FieldParser { // Set label this.setLabel(controlModel, label); if (hint) { - controlModel.hint = this.configData.hints; + controlModel.hint = this.configData.hints || ' '; } controlModel.placeholder = this.configData.label; @@ -292,9 +299,46 @@ export abstract class FieldParser { (controlModel as DsDynamicInputModel).languageCodes = this.configData.languageCodes; } + // If typeBind is configured + if (isNotEmpty(this.configData.typeBind)) { + (controlModel as DsDynamicInputModel).typeBindRelations = this.getTypeBindRelations(this.configData.typeBind, + this.parserOptions.typeField); + } + return controlModel; } + /** + * Get the type bind values from the REST data for a specific field + * The return value is any[] in the method signature but in reality it's + * returning the 'relation' that'll be used for a dynamic matcher when filtering + * fields in type bind, made up of a 'match' outcome (make this field visible), an 'operator' + * (OR) and a 'when' condition (the bindValues array). + * @param configuredTypeBindValues array of types from the submission definition (CONFIG_DATA) + * @private + * @return DynamicFormControlRelation[] array with one relation in it, for type bind matching to show a field + */ + private getTypeBindRelations(configuredTypeBindValues: string[], typeField: string): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: typeField, + value: value + }); + }); + // match: MATCH_VISIBLE means that if true, the field / component will be visible + // operator: OR means that all the values in the 'when' condition will be compared with OR, not AND + // when: the list of values to match against, in this case the list of strings from ... + // Example: Field [x] will be VISIBLE if item type = book OR item type = book_part + // + // The opposing match value will be the dc.type for the workspace item + return [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues + }]; + } + protected hasRegex() { return hasValue(this.configData.input.regex); } diff --git a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts index 8a05b169fd..30d1913a51 100644 --- a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts @@ -13,7 +13,8 @@ describe('ListFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts index 87cee9d950..24efcf3462 100644 --- a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('LookupFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts index 3d02b6952e..d0281681ef 100644 --- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('LookupNameFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts index 514585f03f..6b520142cc 100644 --- a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts @@ -14,7 +14,8 @@ describe('NameFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts index 8ecce24194..e7e68a6461 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts @@ -15,7 +15,8 @@ describe('OneboxFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/parser-options.ts b/src/app/shared/form/builder/parsers/parser-options.ts index 8b0b42008e..f7aac3449d 100644 --- a/src/app/shared/form/builder/parsers/parser-options.ts +++ b/src/app/shared/form/builder/parsers/parser-options.ts @@ -2,4 +2,5 @@ export interface ParserOptions { readOnly: boolean; submissionScope: string; collectionUUID: string; + typeField: string; } diff --git a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts index 111193a637..7d48ad2d00 100644 --- a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('RelationGroupFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: 'WORKSPACE' + collectionUUID: 'WORKSPACE', + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/row-parser.spec.ts b/src/app/shared/form/builder/parsers/row-parser.spec.ts index e612534d55..1f9bde8a7f 100644 --- a/src/app/shared/form/builder/parsers/row-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/row-parser.spec.ts @@ -22,6 +22,7 @@ describe('RowParser test suite', () => { const initFormValues = {}; const submissionScope = 'WORKSPACE'; const readOnly = false; + const typeField = 'dc_type'; beforeEach(() => { row1 = { @@ -338,7 +339,7 @@ describe('RowParser test suite', () => { it('should return a DynamicRowGroupModel object', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel instanceof DynamicRowGroupModel).toBe(true); }); @@ -346,7 +347,7 @@ describe('RowParser test suite', () => { it('should return a row with three fields', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect((rowModel as DynamicRowGroupModel).group.length).toBe(3); }); @@ -354,7 +355,7 @@ describe('RowParser test suite', () => { it('should return a DynamicRowArrayModel object', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row2, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row2, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel instanceof DynamicRowArrayModel).toBe(true); }); @@ -362,7 +363,7 @@ describe('RowParser test suite', () => { it('should return a row that contains only scoped fields', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row3, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row3, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect((rowModel as DynamicRowGroupModel).group.length).toBe(1); }); @@ -370,7 +371,7 @@ describe('RowParser test suite', () => { it('should be able to parse a dropdown combo field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row4, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row4, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -378,7 +379,7 @@ describe('RowParser test suite', () => { it('should be able to parse a lookup-name field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row5, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row5, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -386,7 +387,7 @@ describe('RowParser test suite', () => { it('should be able to parse a list field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row6, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row6, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -394,7 +395,7 @@ describe('RowParser test suite', () => { it('should be able to parse a date field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row7, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row7, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -402,7 +403,7 @@ describe('RowParser test suite', () => { it('should be able to parse a tag field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row8, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row8, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -410,7 +411,7 @@ describe('RowParser test suite', () => { it('should be able to parse a textarea field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row9, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row9, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -418,7 +419,7 @@ describe('RowParser test suite', () => { it('should be able to parse a group field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row10, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row10, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); diff --git a/src/app/shared/form/builder/parsers/row-parser.ts b/src/app/shared/form/builder/parsers/row-parser.ts index fe664305b0..764f52ffdf 100644 --- a/src/app/shared/form/builder/parsers/row-parser.ts +++ b/src/app/shared/form/builder/parsers/row-parser.ts @@ -31,7 +31,8 @@ export class RowParser { scopeUUID, initFormValues: any, submissionScope, - readOnly: boolean): DynamicRowGroupModel { + readOnly: boolean, + typeField: string): DynamicRowGroupModel { let fieldModel: any = null; let parsedResult = null; const config: DynamicFormGroupModelConfig = { @@ -47,7 +48,8 @@ export class RowParser { const parserOptions: ParserOptions = { readOnly: readOnly, submissionScope: submissionScope, - collectionUUID: scopeUUID + collectionUUID: scopeUUID, + typeField: typeField }; // Iterate over row's fields diff --git a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts index b044f43833..0761cfe60e 100644 --- a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('SeriesFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts index 7c63235f67..115829f8d3 100644 --- a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('TagFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts index a81907aa13..855e464f21 100644 --- a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('TextareaFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/mocks/find-id-config-data.service.mock.ts b/src/app/shared/mocks/find-id-config-data.service.mock.ts new file mode 100644 index 0000000000..c94fa6c0d6 --- /dev/null +++ b/src/app/shared/mocks/find-id-config-data.service.mock.ts @@ -0,0 +1,14 @@ +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; + +export function getMockFindByIdDataService(propertyKey: string, ...values: string[]) { + return jasmine.createSpyObj('findByIdDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: propertyKey, + values: values, + }) + }); +} + + diff --git a/src/app/shared/mocks/form-builder-service.mock.ts b/src/app/shared/mocks/form-builder-service.mock.ts index e37df20e13..6344ac6a6f 100644 --- a/src/app/shared/mocks/form-builder-service.mock.ts +++ b/src/app/shared/mocks/form-builder-service.mock.ts @@ -1,7 +1,9 @@ import { FormBuilderService } from '../form/builder/form-builder.service'; import { FormControl, FormGroup } from '@angular/forms'; +import {DsDynamicInputModel} from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; export function getMockFormBuilderService(): FormBuilderService { + return jasmine.createSpyObj('FormBuilderService', { modelFromConfiguration: [], createFormGroup: new FormGroup({}), @@ -17,8 +19,26 @@ export function getMockFormBuilderService(): FormBuilderService { isQualdropGroup: false, isModelInCustomGroup: true, isRelationGroup: true, - hasArrayGroupValue: true - + hasArrayGroupValue: true, + getTypeBindModel: new DsDynamicInputModel({ + name: 'dc.type', + id: 'dc_type', + readOnly: false, + disabled: false, + repeatable: false, + value: { + value: 'boundType', + display: 'Bound Type', + authority: 'bound-auth-key' + }, + submissionId: '1234', + metadataFields: ['dc.type'], + hasSelectableMetadata: false, + typeBindRelations: [ + {match: 'VISIBLE', operator: 'OR', when: [{id: 'dc.type', value: 'boundType'}]} + ] + } + ) }); } diff --git a/src/app/shared/mocks/form-models.mock.ts b/src/app/shared/mocks/form-models.mock.ts index c43138fa25..3529f9e81b 100644 --- a/src/app/shared/mocks/form-models.mock.ts +++ b/src/app/shared/mocks/form-models.mock.ts @@ -89,7 +89,8 @@ const rowArrayQualdropConfig = { submissionId: '1234', metadataKey: 'dc.some.key', metadataFields: ['dc.some.key'], - hasSelectableMetadata: false + hasSelectableMetadata: false, + showButtons: true } as DynamicRowArrayModelConfig; export const MockRowArrayQualdropModel: DynamicRowArrayModel = new DynamicRowArrayModel(rowArrayQualdropConfig); @@ -305,3 +306,57 @@ export const mockFileFormEditRowGroupModel = new DynamicRowGroupModel({ id: 'mockRowGroupModel', group: [mockFileFormEditInputModel] }); + +// Mock configuration and model for an input with type binding +export const inputWithTypeBindConfig = { + name: 'testWithTypeBind', + id: 'testWithTypeBind', + readOnly: false, + disabled: false, + repeatable: false, + value: { + value: 'testWithTypeBind', + display: 'testWithTypeBind', + authority: 'bound-auth-key' + }, + submissionId: '1234', + metadataFields: [], + hasSelectableMetadata: false, + getTypeBindModel: new DsDynamicInputModel({ + name: 'testWithTypeBind', + id: 'testWithTypeBind', + readOnly: false, + disabled: false, + repeatable: false, + value: { + value: 'testWithTypeBind', + display: 'testWithTypeBind', + authority: 'bound-auth-key' + }, + submissionId: '1234', + metadataFields: [], + hasSelectableMetadata: false, + typeBindRelations: [ + {match: 'VISIBLE', operator: 'OR', when: [{'id': 'dc.type', 'value': 'boundType'}]} + ] + } + ) +}; + +export const mockInputWithTypeBindModel = new DsDynamicInputModel(inputWithAuthorityValueConfig); + +export const dcTypeInputConfig = { + name: 'dc.type', + id: 'dc_type', + readOnly: false, + disabled: false, + repeatable: false, + submissionId: '1234', + metadataFields: [], + hasSelectableMetadata: false, + value: { + value: 'boundType' + } +}; + +export const mockDcTypeInputModel = new DsDynamicInputModel(dcTypeInputConfig); diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html index 7fdb505d43..4344cf9a00 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html @@ -18,6 +18,7 @@
+

diff --git a/src/app/shared/object-list/access-status-badge/access-status-badge.component.html b/src/app/shared/object-list/access-status-badge/access-status-badge.component.html new file mode 100644 index 0000000000..3877663419 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.html @@ -0,0 +1,5 @@ + +
+ {{ accessStatus | translate }} +
+
diff --git a/src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts b/src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts new file mode 100644 index 0000000000..9101df2f4c --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts @@ -0,0 +1,163 @@ +import { Item } from '../../../core/shared/item.model'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { TruncatePipe } from '../../utils/truncate.pipe'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { AccessStatusBadgeComponent } from './access-status-badge.component'; +import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { By } from '@angular/platform-browser'; +import { AccessStatusObject } from './access-status.model'; +import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; +import { environment } from 'src/environments/environment'; + +describe('ItemAccessStatusBadgeComponent', () => { + let component: AccessStatusBadgeComponent; + let fixture: ComponentFixture; + + let unknownStatus: AccessStatusObject; + let metadataOnlyStatus: AccessStatusObject; + let openAccessStatus: AccessStatusObject; + let embargoStatus: AccessStatusObject; + let restrictedStatus: AccessStatusObject; + + let accessStatusDataService: AccessStatusDataService; + + let item: Item; + + function init() { + unknownStatus = Object.assign(new AccessStatusObject(), { + status: 'unknown' + }); + + metadataOnlyStatus = Object.assign(new AccessStatusObject(), { + status: 'metadata.only' + }); + + openAccessStatus = Object.assign(new AccessStatusObject(), { + status: 'open.access' + }); + + embargoStatus = Object.assign(new AccessStatusObject(), { + status: 'embargo' + }); + + restrictedStatus = Object.assign(new AccessStatusObject(), { + status: 'restricted' + }); + + accessStatusDataService = jasmine.createSpyObj('accessStatusDataService', { + findAccessStatusFor: createSuccessfulRemoteDataObject$(unknownStatus) + }); + + item = Object.assign(new Item(), { + uuid: 'item-uuid' + }); + } + + function initTestBed() { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [AccessStatusBadgeComponent, TruncatePipe], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + {provide: AccessStatusDataService, useValue: accessStatusDataService} + ] + }).compileComponents(); + } + + function initFixtureAndComponent() { + environment.item.showAccessStatuses = true; + fixture = TestBed.createComponent(AccessStatusBadgeComponent); + component = fixture.componentInstance; + component.item = item; + fixture.detectChanges(); + environment.item.showAccessStatuses = false; + } + + function lookForAccessStatusBadge(status: string) { + const badge = fixture.debugElement.query(By.css('span.badge')); + expect(badge.nativeElement.textContent).toEqual(`access-status.${status.toLowerCase()}.listelement.badge`); + } + + describe('init', () => { + beforeEach(waitForAsync(() => { + init(); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should init the component', () => { + expect(component).toBeTruthy(); + }); + }); + + describe('When the findAccessStatusFor method returns unknown', () => { + beforeEach(waitForAsync(() => { + init(); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the unknown badge', () => { + lookForAccessStatusBadge('unknown'); + }); + }); + + describe('When the findAccessStatusFor method returns metadata.only', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(metadataOnlyStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the metadata only badge', () => { + lookForAccessStatusBadge('metadata.only'); + }); + }); + + describe('When the findAccessStatusFor method returns open.access', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(openAccessStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the open access badge', () => { + lookForAccessStatusBadge('open.access'); + }); + }); + + describe('When the findAccessStatusFor method returns embargo', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(embargoStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the embargo badge', () => { + lookForAccessStatusBadge('embargo'); + }); + }); + + describe('When the findAccessStatusFor method returns restricted', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(restrictedStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the restricted badge', () => { + lookForAccessStatusBadge('restricted'); + }); + }); +}); diff --git a/src/app/shared/object-list/access-status-badge/access-status-badge.component.ts b/src/app/shared/object-list/access-status-badge/access-status-badge.component.ts new file mode 100644 index 0000000000..fbca3cb971 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.ts @@ -0,0 +1,57 @@ +import { Component, Input } from '@angular/core'; +import { catchError, map } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { AccessStatusObject } from './access-status.model'; +import { hasValue } from '../../empty.util'; +import { environment } from 'src/environments/environment'; +import { Item } from 'src/app/core/shared/item.model'; +import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; + +@Component({ + selector: 'ds-access-status-badge', + templateUrl: './access-status-badge.component.html' +}) +/** + * Component rendering the access status of an item as a badge + */ +export class AccessStatusBadgeComponent { + + @Input() item: Item; + accessStatus$: Observable; + + /** + * Whether to show the access status badge or not + */ + showAccessStatus: boolean; + + /** + * Initialize instance variables + * + * @param {AccessStatusDataService} accessStatusDataService + */ + constructor(private accessStatusDataService: AccessStatusDataService) { } + + ngOnInit(): void { + this.showAccessStatus = environment.item.showAccessStatuses; + if (!this.showAccessStatus || this.item == null) { + // Do not show the badge if the feature is inactive or if the item is null. + return; + } + if (this.item.accessStatus == null) { + // In case the access status has not been loaded, do it individually. + this.item.accessStatus = this.accessStatusDataService.findAccessStatusFor(this.item); + } + this.accessStatus$ = this.item.accessStatus.pipe( + map((accessStatusRD) => { + if (accessStatusRD.statusCode !== 401 && hasValue(accessStatusRD.payload)) { + return accessStatusRD.payload; + } else { + return []; + } + }), + map((accessStatus: AccessStatusObject) => hasValue(accessStatus.status) ? accessStatus.status : 'unknown'), + map((status: string) => `access-status.${status.toLowerCase()}.listelement.badge`), + catchError(() => observableOf('access-status.unknown.listelement.badge')) + ); + } +} diff --git a/src/app/shared/object-list/access-status-badge/access-status.model.ts b/src/app/shared/object-list/access-status-badge/access-status.model.ts new file mode 100644 index 0000000000..69b5e920d0 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status.model.ts @@ -0,0 +1,33 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from 'src/app/core/cache/builders/build-decorators'; +import { CacheableObject } from 'src/app/core/cache/cacheable-object.model'; +import { HALLink } from 'src/app/core/shared/hal-link.model'; +import { ResourceType } from 'src/app/core/shared/resource-type'; +import { excludeFromEquals } from 'src/app/core/utilities/equals.decorators'; +import { ACCESS_STATUS } from './access-status.resource-type'; + +@typedObject +export class AccessStatusObject implements CacheableObject { + static type = ACCESS_STATUS; + + /** + * The type for this AccessStatusObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The access status value + */ + @autoserialize + status: string; + + /** + * The {@link HALLink}s for this AccessStatusObject + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/shared/object-list/access-status-badge/access-status.resource-type.ts b/src/app/shared/object-list/access-status-badge/access-status.resource-type.ts new file mode 100644 index 0000000000..ead2afc0b1 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from 'src/app/core/shared/resource-type'; + +/** + * The resource type for Access Status + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ACCESS_STATUS = new ResourceType('accessStatus'); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index c518d39bd9..6d4c704b60 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -2,7 +2,10 @@ - +
+ + +

diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index 840960d51f..34b2d979c1 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -35,5 +35,4 @@ export class ItemListPreviewComponent { * A boolean representing if to show submitter information */ @Input() showSubmitter = false; - } diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html index 8898632eb5..645cace8dc 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html @@ -1,4 +1,7 @@ - +
+ + +
('thumbnail', { isOptional: true }) + followLink('thumbnail', { isOptional: true }), + followLink('accessStatus', { isOptional: true, shouldEmbed: environment.item.showAccessStatuses }) ).pipe(getFirstCompletedRemoteData()) .subscribe((results: RemoteData>) => { if (results.hasSucceeded && results.payload?.page?.length > 0) { diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 37f4fafb1e..12b6a482dc 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -111,6 +111,7 @@ import { FilterInputSuggestionsComponent } from './input-suggestions/filter-sugg import { DsoInputSuggestionsComponent } from './input-suggestions/dso-input-suggestions/dso-input-suggestions.component'; import { ItemGridElementComponent } from './object-grid/item-grid-element/item-types/item/item-grid-element.component'; import { TypeBadgeComponent } from './object-list/type-badge/type-badge.component'; +import { AccessStatusBadgeComponent } from './object-list/access-status-badge/access-status-badge.component'; import { MetadataRepresentationLoaderComponent } from './metadata-representation/metadata-representation-loader.component'; import { MetadataRepresentationDirective } from './metadata-representation/metadata-representation.directive'; import { ListableObjectComponentLoaderComponent } from './object-collection/shared/listable-object/listable-object-component-loader.component'; @@ -293,6 +294,7 @@ const COMPONENTS = [ AbstractTrackableComponent, ComcolMetadataComponent, TypeBadgeComponent, + AccessStatusBadgeComponent, BrowseByComponent, AbstractTrackableComponent, diff --git a/src/app/shared/testing/hal-endpoint-service.stub.ts b/src/app/shared/testing/hal-endpoint-service.stub.ts index 19f95d577c..753efcdb5d 100644 --- a/src/app/shared/testing/hal-endpoint-service.stub.ts +++ b/src/app/shared/testing/hal-endpoint-service.stub.ts @@ -1,9 +1,13 @@ import { of as observableOf } from 'rxjs'; +import { hasValue } from '../empty.util'; export class HALEndpointServiceStub { constructor(private url: string) {} - getEndpoint(path: string) { + getEndpoint(path: string, startHref?: string) { + if (hasValue(startHref)) { + return observableOf(startHref + '/' + path); + } return observableOf(this.url + '/' + path); } } diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index d5798b82c8..65ddbe0cb0 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -814,7 +814,9 @@ describe('SectionFormOperationsService test suite', () => { required: false, metadataKey: 'dc.contributor.author', metadataFields: ['dc.contributor.author'], - hasSelectableMetadata: true + hasSelectableMetadata: true, + showButtons: true, + typeBindRelations: [] } ); spyOn(serviceAsAny, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); diff --git a/src/app/submission/submission.module.ts b/src/app/submission/submission.module.ts index 939d1bff29..05aa765054 100644 --- a/src/app/submission/submission.module.ts +++ b/src/app/submission/submission.module.ts @@ -97,7 +97,7 @@ const DECLARATIONS = [ SectionsService, SubmissionUploadsConfigService, SubmissionAccessesConfigService, - SectionAccessesService + SectionAccessesService, ] }) diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 76f4f538ea..d30661f3d7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -27,6 +27,16 @@ "404.page-not-found": "page not found", + "access-status.embargo.listelement.badge": "Embargo", + + "access-status.metadata.only.listelement.badge": "Metadata only", + + "access-status.open.access.listelement.badge": "Open Access", + + "access-status.restricted.listelement.badge": "Restricted", + + "access-status.unknown.listelement.badge": "Unknown", + "admin.curation-tasks.breadcrumbs": "System curation tasks", "admin.curation-tasks.title": "System curation tasks", diff --git a/src/assets/i18n/fr.json5 b/src/assets/i18n/fr.json5 index 0f14b78341..3b9d7498be 100644 --- a/src/assets/i18n/fr.json5 +++ b/src/assets/i18n/fr.json5 @@ -36,6 +36,21 @@ // "404.page-not-found": "page not found", "404.page-not-found": "Page introuvable", + // "access-status.embargo.listelement.badge": "Embargo", + "access-status.embargo.listelement.badge": "Restriction temporaire", + + // "access-status.metadata.only.listelement.badge": "Metadata only", + "access-status.metadata.only.listelement.badge": "Métadonnées seulement", + + // "access-status.open.access.listelement.badge": "Open Access", + "access-status.open.access.listelement.badge": "Accès libre", + + // "access-status.restricted.listelement.badge": "Restricted", + "access-status.restricted.listelement.badge": "Restreint", + + // "access-status.unknown.listelement.badge": "Unknown", + "access-status.unknown.listelement.badge": "Inconnu", + // "admin.curation-tasks.breadcrumbs": "System curation tasks", "admin.curation-tasks.breadcrumbs": "Tâches de conservation système", diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index a41ec05b82..8bfa1f66f1 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -7,7 +7,7 @@ import { INotificationBoardOptions } from './notifications-config.interfaces'; import { SubmissionConfig } from './submission-config.interface'; import { FormConfig } from './form-config.interfaces'; import { LangConfig } from './lang-config.interface'; -import { ItemPageConfig } from './item-page-config.interface'; +import { ItemConfig } from './item-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; import { ThemeConfig } from './theme.model'; import { AuthConfig } from './auth-config.interfaces'; @@ -29,7 +29,7 @@ interface AppConfig extends Config { defaultLanguage: string; languages: LangConfig[]; browseBy: BrowseByConfig; - item: ItemPageConfig; + item: ItemConfig; collection: CollectionPageConfig; themes: ThemeConfig[]; mediaViewer: MediaViewerConfig; diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index a7360bd1d1..476351b403 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -6,7 +6,7 @@ import { BrowseByConfig } from './browse-by-config.interface'; import { CacheConfig } from './cache-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; import { FormConfig } from './form-config.interfaces'; -import { ItemPageConfig } from './item-page-config.interface'; +import { ItemConfig } from './item-config.interface'; import { LangConfig } from './lang-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; @@ -111,6 +111,9 @@ export class DefaultAppConfig implements AppConfig { */ timer: 0 }, + typeBind: { + field: 'dc.type' + }, icons: { metadata: [ /** @@ -198,11 +201,13 @@ export class DefaultAppConfig implements AppConfig { defaultLowerLimit: 1900 }; - // Item Page Config - item: ItemPageConfig = { + // Item Config + item: ItemConfig = { edit: { undoTimeout: 10000 // 10 seconds - } + }, + // Show the item access status label in items lists + showAccessStatuses: false }; // Collection Page Config diff --git a/src/config/item-config.interface.ts b/src/config/item-config.interface.ts new file mode 100644 index 0000000000..f842c37c05 --- /dev/null +++ b/src/config/item-config.interface.ts @@ -0,0 +1,9 @@ +import { Config } from './config.interface'; + +export interface ItemConfig extends Config { + edit: { + undoTimeout: number; + }; + // This is used to show the access status label of items in results lists + showAccessStatuses: boolean; +} diff --git a/src/config/item-page-config.interface.ts b/src/config/item-page-config.interface.ts deleted file mode 100644 index 2b05e28715..0000000000 --- a/src/config/item-page-config.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Config } from './config.interface'; - -export interface ItemPageConfig extends Config { - edit: { - undoTimeout: number; - }; -} diff --git a/src/config/submission-config.interface.ts b/src/config/submission-config.interface.ts index ce275b9bf8..a63af45e38 100644 --- a/src/config/submission-config.interface.ts +++ b/src/config/submission-config.interface.ts @@ -5,6 +5,10 @@ interface AutosaveConfig extends Config { timer: number; } +interface TypeBindConfig extends Config { + field: string; +} + interface IconsConfig extends Config { metadata: MetadataIconConfig[]; authority: { @@ -24,5 +28,6 @@ export interface ConfidenceIconConfig extends Config { export interface SubmissionConfig extends Config { autosave: AutosaveConfig; + typeBind: TypeBindConfig; icons: IconsConfig; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index bc1622f5f2..4fb68521fc 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -100,6 +100,9 @@ export const environment: BuildConfig = { // NOTE: every how many minutes submission is saved automatically timer: 5 }, + typeBind: { + field: 'dc.type' + }, icons: { metadata: [ { @@ -196,7 +199,9 @@ export const environment: BuildConfig = { item: { edit: { undoTimeout: 10000 // 10 seconds - } + }, + // Show the item access status label in items lists + showAccessStatuses: false }, collection: { edit: { diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss index 4afd2eb05e..4a855a635e 100644 --- a/src/styles/_global-styles.scss +++ b/src/styles/_global-styles.scss @@ -108,3 +108,10 @@ ngb-modal-backdrop { .ml-gap { margin-left: var(--ds-gap); } + +ds-dynamic-form-control-container.d-none { + /* Ensures that form-control containers hidden and disabled by type binding collapse and let other fields in + the same row expand accordingly + */ + visibility: collapse; +}