From 459da211bede61fb6f2170b96a80293885bdac6c Mon Sep 17 00:00:00 2001 From: nibou230 Date: Wed, 13 Apr 2022 13:03:37 -0400 Subject: [PATCH 01/41] Display the access status badges --- config/config.example.yml | 2 + src/app/core/core.module.ts | 4 +- src/app/core/data/item-data.service.ts | 22 +++ .../access-status-badge.component.html | 3 + .../access-status-badge.component.spec.ts | 160 ++++++++++++++++++ .../access-status-badge.component.ts | 45 +++++ .../access-status.model.ts | 23 +++ .../access-status.resource-type.ts | 9 + ...-search-result-list-element.component.html | 5 +- ...em-search-result-list-element.component.ts | 6 + src/app/shared/shared.module.ts | 2 + src/assets/i18n/en.json5 | 10 ++ src/assets/i18n/fr.json5 | 15 ++ src/config/default-app-config.ts | 5 +- src/config/ui-server-config.interface.ts | 2 + src/environments/environment.test.ts | 4 +- 16 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/app/shared/object-list/access-status-badge/access-status-badge.component.html create mode 100644 src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts create mode 100644 src/app/shared/object-list/access-status-badge/access-status-badge.component.ts create mode 100644 src/app/shared/object-list/access-status-badge/access-status.model.ts create mode 100644 src/app/shared/object-list/access-status-badge/access-status.resource-type.ts diff --git a/config/config.example.yml b/config/config.example.yml index 771c7b1653..724d7f8a64 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -13,6 +13,8 @@ ui: rateLimiter: windowMs: 60000 # 1 minute max: 500 # limit each IP to 500 requests per windowMs + # Show the file access status in items lists + showAccessStatuses: false # The REST API server settings # NOTE: these must be 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e9e242dbc0..b90a3edd46 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -163,6 +163,7 @@ 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'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -346,7 +347,8 @@ export const models = UsageReport, Root, SearchConfig, - SubmissionAccessesModel + SubmissionAccessesModel, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index cb5d7a3d57..9a2dc5a8af 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -36,6 +36,7 @@ import { sendRequest } from '../shared/request.operators'; import { RestRequest } from './rest-request.model'; import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; @Injectable() @dataService(ITEM) @@ -291,6 +292,27 @@ export class ItemDataService extends DataService { ); } + /** + * Get the the access status + * @param itemId + */ + public getAccessStatus(itemId: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint('accessStatus').pipe( + map((href) => href.replace('{?uuid}', `?uuid=${itemId}`)) + ); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new GetRequest(requestId, href); + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + /** * Invalidate the cache of the item * @param itemUUID 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..a7b3edc9b3 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.html @@ -0,0 +1,3 @@ +
+ {{ status | 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..da0677a5de --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts @@ -0,0 +1,160 @@ +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 { ItemDataService } from 'src/app/core/data/item-data.service'; +import { AccessStatusObject } from './access-status.model'; + +describe('ItemAccessStatusBadgeComponent', () => { + let component: AccessStatusBadgeComponent; + let fixture: ComponentFixture; + + let unknownStatus: AccessStatusObject; + let metadataOnlyStatus: AccessStatusObject; + let openAccessStatus: AccessStatusObject; + let embargoStatus: AccessStatusObject; + let restrictedStatus: AccessStatusObject; + + let itemDataService: ItemDataService; + + 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' + }); + + itemDataService = jasmine.createSpyObj('itemDataService', { + getAccessStatus: 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: ItemDataService, useValue: itemDataService} + ] + }).compileComponents(); + } + + function initFixtureAndComponent() { + fixture = TestBed.createComponent(AccessStatusBadgeComponent); + component = fixture.componentInstance; + component.uuid = item.uuid; + fixture.detectChanges(); + } + + 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 getAccessStatus method returns unknown', () => { + beforeEach(waitForAsync(() => { + init(); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the unknown badge', () => { + lookForAccessStatusBadge('unknown'); + }); + }); + + describe('When the getAccessStatus method returns metadata.only', () => { + beforeEach(waitForAsync(() => { + init(); + (itemDataService.getAccessStatus as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(metadataOnlyStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the metadata only badge', () => { + lookForAccessStatusBadge('metadata.only'); + }); + }); + + describe('When the getAccessStatus method returns open.access', () => { + beforeEach(waitForAsync(() => { + init(); + (itemDataService.getAccessStatus as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(openAccessStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the open access badge', () => { + lookForAccessStatusBadge('open.access'); + }); + }); + + describe('When the getAccessStatus method returns embargo', () => { + beforeEach(waitForAsync(() => { + init(); + (itemDataService.getAccessStatus as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(embargoStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the embargo badge', () => { + lookForAccessStatusBadge('embargo'); + }); + }); + + describe('When the getAccessStatus method returns restricted', () => { + beforeEach(waitForAsync(() => { + init(); + (itemDataService.getAccessStatus 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..b181ae4104 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.ts @@ -0,0 +1,45 @@ +import { Component, Input } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { getFirstSucceededRemoteDataPayload } from 'src/app/core/shared/operators'; +import { ItemDataService } from 'src/app/core/data/item-data.service'; +import { AccessStatusObject } from './access-status.model'; +import { hasValue } from '../../empty.util'; + +@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 { + + private _uuid: string; + private _accessStatus$: Observable; + + /** + * Initialize instance variables + * + * @param {ItemDataService} itemDataService + */ + constructor(private itemDataService: ItemDataService) { } + + ngOnInit(): void { + this._accessStatus$ = this.itemDataService + .getAccessStatus(this._uuid) + .pipe( + getFirstSucceededRemoteDataPayload(), + map((accessStatus: AccessStatusObject) => hasValue(accessStatus.status) ? accessStatus.status : 'unknown'), + map((status: string) => `access-status.${status.toLowerCase()}.listelement.badge`) + ); + } + + @Input() set uuid(uuid: string) { + this._uuid = uuid; + } + + get accessStatus$(): Observable { + return this._accessStatus$; + } +} 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..6cdcafdbd8 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status.model.ts @@ -0,0 +1,23 @@ +import { autoserialize } from 'cerialize'; +import { typedObject } from 'src/app/core/cache/builders/build-decorators'; +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 { + static type = ACCESS_STATUS; + + /** + * The type for this AccessStatusObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The access status value + */ + @autoserialize + status: string; +} 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/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..8bea795cca 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 @@ - +
+ + +
Date: Wed, 20 Apr 2022 09:12:59 -0400 Subject: [PATCH 02/41] Adapt the service to a LinkedRepository result --- src/app/core/data/item-data.service.spec.ts | 36 ++++++++++++++++--- src/app/core/data/item-data.service.ts | 30 +++++++++------- .../data/version-history-data.service.spec.ts | 2 +- .../access-status.model.ts | 14 ++++++-- .../testing/hal-endpoint-service.stub.ts | 6 +++- 5 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index cc1e3b6e20..e85ddb2f38 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: {} }); @@ -185,6 +184,33 @@ describe('ItemDataService', () => { }); }); + describe('getAccessStatusEndpoint', () => { + beforeEach(() => { + service = initTestService(); + }); + it('should retrieve the access status endpoint', () => { + const itemId = '3de6ea60-ec39-419b-ae6f-065930ac1429'; + const result = service.getAccessStatusEndpoint(itemId); + result.subscribe(((value) => { + expect(value).toEqual(`${itemEndpoint}/items/${itemId}/accessStatus`); + })); + }); + }); + + describe('getAccessStatus', () => { + beforeEach(() => { + service = initTestService(); + }); + it('should send a GET request', (done) => { + const itemId = '3de6ea60-ec39-419b-ae6f-065930ac1429'; + const result = service.getAccessStatus(itemId); + result.subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest)); + done(); + }); + }); + }); + describe('when cache is invalidated', () => { beforeEach(() => { service = initTestService(); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 9a2dc5a8af..c7f0f541f8 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -292,25 +292,31 @@ export class ItemDataService extends DataService { ); } + /** + * Get the endpoint for an item's access status + * @param itemId + */ + public getAccessStatusEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((url: string) => this.halService.getEndpoint('accessStatus', `${url}/${itemId}`)) + ); + } + /** * Get the the access status * @param itemId */ public getAccessStatus(itemId: string): Observable> { - const requestId = this.requestService.generateRequestId(); - const href$ = this.halService.getEndpoint('accessStatus').pipe( - map((href) => href.replace('{?uuid}', `?uuid=${itemId}`)) - ); + const hrefObs = this.getAccessStatusEndpoint(itemId); - href$.pipe( - find((href: string) => hasValue(href)), - map((href: string) => { - const request = new GetRequest(requestId, href); - this.requestService.send(request); - }) - ).subscribe(); + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.send(request); + }); - return this.rdbService.buildFromRequestUUID(requestId); + return this.rdbService.buildSingle(hrefObs); } /** 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/shared/object-list/access-status-badge/access-status.model.ts b/src/app/shared/object-list/access-status-badge/access-status.model.ts index 6cdcafdbd8..31de1a3a38 100644 --- 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 @@ -1,11 +1,13 @@ -import { autoserialize } from 'cerialize'; +import { autoserialize, deserialize } from 'cerialize'; import { typedObject } from 'src/app/core/cache/builders/build-decorators'; +import { CacheableObject } from 'src/app/core/cache/object-cache.reducer'; +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 { +export class AccessStatusObject implements CacheableObject { static type = ACCESS_STATUS; /** @@ -20,4 +22,12 @@ export class AccessStatusObject { */ @autoserialize status: string; + + /** + * The {@link HALLink}s for this AccessStatusObject + */ + @deserialize + _links: { + self: HALLink; + }; } 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); } } From a2a241b906d457164f2954733f8481792e341269 Mon Sep 17 00:00:00 2001 From: nibou230 Date: Thu, 21 Apr 2022 12:32:11 -0400 Subject: [PATCH 03/41] Added the badge to some components for coherence --- ...m-search-result-grid-element.component.html | 1 + ...tem-search-result-grid-element.component.ts | 6 ++++++ .../access-status-badge.component.html | 4 ++-- .../access-status-badge.component.ts | 18 +++++++++++++----- .../item-list-preview.component.html | 5 ++++- .../item-list-preview.component.ts | 9 +++++++++ ...m-search-result-list-element.component.html | 2 +- 7 files changed, 36 insertions(+), 9 deletions(-) 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..c4bb444d44 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-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts index a628c1e7e8..77fc25ff29 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts @@ -6,6 +6,7 @@ import { SearchResultGridElementComponent } from '../../search-result-grid-eleme import { Item } from '../../../../../core/shared/item.model'; import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; import { getItemPageRoute } from '../../../../../item-page/item-page-routing-paths'; +import { environment } from 'src/environments/environment'; @listableObjectComponent('PublicationSearchResult', ViewMode.GridElement) @listableObjectComponent(ItemSearchResult, ViewMode.GridElement) @@ -23,9 +24,14 @@ export class ItemSearchResultGridElementComponent extends SearchResultGridElemen * Route to the item's page */ itemPageRoute: string; + /** + * Whether to show the access status badge or not + */ + showAccessStatus: boolean; ngOnInit(): void { super.ngOnInit(); this.itemPageRoute = getItemPageRoute(this.dso); + this.showAccessStatus = environment.ui.showAccessStatuses; } } 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 index a7b3edc9b3..8fc3a82740 100644 --- 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 @@ -1,3 +1,3 @@ -
- {{ status | translate }} +
+ {{ accessStatus | translate }}
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 index b181ae4104..bf76a429f7 100644 --- 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 @@ -1,7 +1,7 @@ import { Component, Input } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; -import { getFirstSucceededRemoteDataPayload } from 'src/app/core/shared/operators'; +import { catchError, map } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { getFirstCompletedRemoteData } from 'src/app/core/shared/operators'; import { ItemDataService } from 'src/app/core/data/item-data.service'; import { AccessStatusObject } from './access-status.model'; import { hasValue } from '../../empty.util'; @@ -29,9 +29,17 @@ export class AccessStatusBadgeComponent { this._accessStatus$ = this.itemDataService .getAccessStatus(this._uuid) .pipe( - getFirstSucceededRemoteDataPayload(), + getFirstCompletedRemoteData(), + 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`) + map((status: string) => `access-status.${status.toLowerCase()}.listelement.badge`), + catchError(() => observableOf('access-status.unknown.listelement.badge')) ); } 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..4801e66a26 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..6b2290c711 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 @@ -1,4 +1,5 @@ import { Component, Input } from '@angular/core'; +import { environment } from 'src/environments/environment'; import { Item } from '../../../../core/shared/item.model'; import { fadeInOut } from '../../../animations/fade'; @@ -36,4 +37,12 @@ export class ItemListPreviewComponent { */ @Input() showSubmitter = false; + /** + * Whether to show the access status badge or not + */ + showAccessStatus: boolean; + + ngOnInit(): void { + this.showAccessStatus = environment.ui.showAccessStatuses; + } } 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 8bea795cca..8e216bb82c 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,6 +1,6 @@
- +
From 9f50b4997cbe0b6a93c701ceff93596af0b9922b Mon Sep 17 00:00:00 2001 From: nibou230 Date: Thu, 21 Apr 2022 14:30:23 -0400 Subject: [PATCH 04/41] Changed the import for CacheableObject --- .../object-list/access-status-badge/access-status.model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 31de1a3a38..69b5e920d0 100644 --- 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 @@ -1,6 +1,6 @@ import { autoserialize, deserialize } from 'cerialize'; import { typedObject } from 'src/app/core/cache/builders/build-decorators'; -import { CacheableObject } from 'src/app/core/cache/object-cache.reducer'; +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'; From 361bb7f7dc4e4fb3b220f16f34e54b0d605f6824 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 3 Feb 2022 14:05:31 +1300 Subject: [PATCH 05/41] [TLC-254] Port submission field type binding from DSpace-CRIS 7 --- src/app/core/core.module.ts | 2 + src/app/shared/empty.util.ts | 26 ++ ...amic-form-control-container.component.html | 3 +- ...c-form-control-container.component.spec.ts | 10 + ...ynamic-form-control-container.component.ts | 26 +- .../ds-dynamic-type-bind-relation.service.ts | 222 ++++++++++++++++++ .../dynamic-form-array.component.html | 12 +- .../dynamic-form-array.component.ts | 3 + .../models/date-picker/date-picker.model.ts | 37 +++ .../models/ds-dynamic-input.model.ts | 18 +- .../models/ds-dynamic-row-array-model.ts | 28 ++- .../dynamic-form-group.component.html | 12 +- .../dynamic-form-group.component.ts | 5 +- .../form/builder/form-builder.service.ts | 157 ++++++++++++- .../form/builder/models/form-field.model.ts | 6 + .../form/builder/parsers/date-field-parser.ts | 6 +- .../form/builder/parsers/field-parser.ts | 41 +++- 17 files changed, 585 insertions(+), 29 deletions(-) create mode 100644 src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e9e242dbc0..71c3230a38 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'; @@ -250,6 +251,7 @@ const PROVIDERS = [ ClaimedTaskDataService, PoolTaskDataService, BitstreamDataService, + DsDynamicTypeBindRelationService, EntityTypeService, ContentSourceResponseParsingService, ItemTemplateDataService, 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..785b0958d5 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 @@ -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,13 @@ 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'), + isFormControlToBeHidden: jasmine.createSpy('isFormControlToBeHidden') + }); +} + describe('DsDynamicFormControlContainerComponent test suite', () => { const vocabularyOptions: VocabularyOptions = { @@ -142,6 +150,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { submissionId: '1234', id: 'relationGroup', formConfiguration: [], + isInlineGroup: false, mandatoryField: '', name: 'relationGroup', relationFields: [], @@ -200,6 +209,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { providers: [ DsDynamicFormControlContainerComponent, DynamicFormService, + { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, { provide: RelationshipService, useValue: {} }, { provide: SelectableListService, useValue: {} }, { provide: ItemDataService, useValue: {} }, 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..8d27a3bace 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 @@ -76,11 +76,13 @@ import { DsDynamicLookupComponent } from './models/lookup/dynamic-lookup.compone import { DsDynamicFormGroupComponent } from './models/form-group/dynamic-form-group.component'; import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-array.component'; import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; +import { DynamicRelationGroupModel } from './models/relation-group/dynamic-relation-group.model'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model'; 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 +196,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 +241,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 +348,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 +365,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-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..3ec6909c07 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts @@ -0,0 +1,222 @@ +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, + OR_OPERATOR +} from '@ng-dynamic-forms/core'; + +import { isNotUndefined, isUndefined } 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 + */ + private static getTypeBindValue(bindModelValue: string | FormFieldMetadataValueObject): string { + let value; + if (isUndefined(bindModelValue) || typeof bindModelValue === 'string') { + value = bindModelValue; + } else if (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 true if the type bind relation (eg. {MATCH_VISIBLE, OR, ['book', 'book part']}) matches the value in + * matcher.match (or matcher.opposingMatch? not sure what that is), which in this case would be the current dc.type + * of the submission item + * @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, map values to the mandatory field for the model? Don't totally understand + // what is going on here + if (bindModel.type === DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP) { + bindModelValue = bindModel.value.map((entry) => entry[bindModel.mandatoryField]); + } + // If we have an array of values, map the bindModelValue[] back to values[], looking up + // the type bind value for each in the static method here (this just handles cases where authority should + // be used, or where the entry doesn't have .value but is a string itself, etc) + // If values isn't an array, make it a single element array with the looked-up type bind value. + if (Array.isArray(bindModelValue)) { + values = [...bindModelValue.map((entry) => DsDynamicTypeBindRelationService.getTypeBindValue(entry))]; + } else { + values = [DsDynamicTypeBindRelationService.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. I'm not sure why the negation is used here! + // Perhaps as a fail-safe for a bad mind model but an exact match of the strings in relation and matcher + // passed to this method. + 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. This is just a kind of short-hand put in here for some kind of + // optimisation, I guess, since the AND requires all values to match, and if we're on index > 0 but haven't + // matched then we've already failed. But surely it's simpler and just as optimal to break on the first + // non-match if using the AND operator?! + // In the case of default type bind usage, we always use OR anyway. + 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; + } + } + + // Here we have tests using 'opposingMatch' which I'm not certain about yet + // It looks like a negation of sorts? Or a 'not equals' comparison used in combination I think? + 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 + 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 (isNotUndefined(relatedModel)) { + const initValue = (isUndefined(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; + // I still don't fully understand what is happening here, or the triggers in various form usage that + // cause which / what to fire change events, why the matcher has onChange() instead of a field value or + // form model, etc. + subscriptions.push(valueChanges.subscribe(() => { + // Iterate each matcher + 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; + } +} 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..29df7a34c4 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,15 +14,17 @@ cdkDrag cdkDragHandle [cdkDragDisabled]="dragDisabled" - [cdkDragPreviewClass]="'ds-submission-reorder-dragging'"> + [cdkDragPreviewClass]="'ds-submission-reorder-dragging'" + [class.grey-background]="model.isInlineGroupArray"> -
- +
+
- - -
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..94b66b288a 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,35 @@ import { DynamicDateControlModel, DynamicDatePickerModelConfig, DynamicFormControlLayout, + DynamicFormControlModel, + DynamicFormControlRelation, serializable } from '@ng-dynamic-forms/core'; +import {BehaviorSubject, Subject} from 'rxjs'; +import {isEmpty, isNotEmpty, 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[]; + securityLevel?: number; + securityConfigLevel?: number[]; + toggleSecurityVisibility?: boolean; } /** * 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; + @serializable() securityLevel?: number; + @serializable() securityConfigLevel?: number[]; + @serializable() toggleSecurityVisibility = true; malformedDate: boolean; legend: string; hasLanguages = false; @@ -25,6 +40,28 @@ 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.hiddenUpdates.subscribe((hidden: boolean) => { + this.hidden = hidden; + const parentModel = this.getRootParent(this); + if (parentModel && isNotUndefined(parentModel.hidden)) { + parentModel.hidden = hidden; + } + }); + } + + private getRootParent(model: any): DynamicFormControlModel { + if (isEmpty(model) || isEmpty(model.parent)) { + return model; + } else { + return this.getRootParent(model.parent); + } + } + + get hasSecurityLevel(): boolean { + return isNotEmpty(this.securityLevel); } } 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..a9adb9a8e9 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, + DynamicFormControlLayout, DynamicFormControlModel, + DynamicFormControlRelation, DynamicInputModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; -import { Subject } from 'rxjs'; +import {BehaviorSubject, 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, isEmpty, isNotUndefined} 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..e4b18a4feb 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 { isNotUndefined } 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 (isNotUndefined(config.notRepeatable)) { + this.notRepeatable = config.notRepeatable; + } + if (isNotUndefined(config.required)) { + this.required = config.required; + } + if (isNotUndefined(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..933590b459 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,8 +1,13 @@ +
-
- - +
+
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.ts b/src/app/shared/form/builder/form-builder.service.ts index 85d70f20dc..33703abf94 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 { AbstractControl, FormArray, 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,7 @@ import { } from '@ng-dynamic-forms/core'; import { isObject, isString, mergeWith } from 'lodash'; -import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined, isNull } from '../../empty.util'; +import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined, isNull, isObjectEmpty } 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'; @@ -36,12 +37,43 @@ import { VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models'; @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; + constructor( componentService: DynamicFormComponentService, validationService: DynamicFormValidationService, protected rowParser: RowParser ) { super(componentService, validationService); + this.formModels = new Map(); + this.formGroups = new Map(); + } + + 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,10 +255,11 @@ 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, securityConfig: any = null): 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); @@ -240,6 +273,13 @@ export class FormBuilderService extends DynamicFormService { }); } + if (isNull(typeBindModel)) { + typeBindModel = this.findById('dc_type', rows); + } + + if (typeBindModel !== null) { + this.setTypeBindModel(typeBindModel); + } return rows; } @@ -309,6 +349,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 +372,82 @@ export class FormBuilderService extends DynamicFormService { return (tempModel.id !== tempModel.name) ? tempModel.name : tempModel.id; } + /** + * Add new form model to formModels map + * @param id id of model + * @param model model + */ + addFormModel(id: string, model: DynamicFormControlModel[]): void { + this.formModels.set(id, model); + } + + /** + * 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); + } + } + + /** + * This method searches a field in all forms instantiated + * by form.component and, if found, it updates its value + * + * @param fieldId id of field to update + * @param value new value to set + * @return the model updated if found + */ + updateModelValue(fieldId: string, value: FormFieldMetadataValueObject): DynamicFormControlModel { + let returnModel = null; + this.formModels.forEach((models, formId) => { + const fieldModel: any = this.findById(fieldId, models); + if (hasValue(fieldModel)) { + if (isNotEmpty(value)) { + if (fieldModel.repeatable && isNotEmpty(fieldModel.value)) { + // if model is repeatable and has already a value add a new field instead of replacing it + const formGroup = this.formGroups.get(formId); + const arrayContext = fieldModel.parent?.context; + if (isNotEmpty(formGroup) && isNotEmpty(arrayContext)) { + const formArrayControl = this.getFormControlByModel(formGroup, arrayContext) as FormArray; + const index = arrayContext?.groups?.length; + this.insertFormArrayGroup(index, formArrayControl, arrayContext); + const newAddedModel: any = this.findById(fieldId, models, index); + this.detectChanges(); + newAddedModel.value = value; + returnModel = newAddedModel; + } + } else { + fieldModel.value = value; + returnModel = fieldModel; + } + } + return; + } + }); + return returnModel; + } + /** * Calculate the metadata list related to the event. * @param event @@ -400,4 +520,29 @@ export class FormBuilderService extends DynamicFormService { return Object.keys(result); } + /** + * Add new formbuilder in forma array by copying current formBuilder index + * @param index index of formBuilder selected to be copied + * @param formArray formArray of the inline group forms + * @param formArrayModel formArrayModel model of forms that will be created + */ + copyFormArrayGroup(index: number, formArray: FormArray, formArrayModel: DynamicFormArrayModel) { + + const groupModel = formArrayModel.insertGroup(index); + const previousGroup = formArray.controls[index] as FormGroup; + const newGroup = this.createFormGroup(groupModel.group, null, groupModel); + const previousKey = Object.keys(previousGroup.getRawValue())[0]; + const newKey = Object.keys(newGroup.getRawValue())[0]; + + if (!isObjectEmpty(previousGroup.getRawValue()[previousKey])) { + newGroup.get(newKey).setValue(previousGroup.getRawValue()[previousKey]); + } + + formArray.insert(index, newGroup); + + return newGroup; + } + + + } 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/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/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index da304ca267..838816ebb1 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 { 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'; @@ -67,6 +67,7 @@ export abstract class FieldParser { metadataFields: this.getAllFieldIds(), hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), isDraggable, + typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind) : null, groupFactory: () => { let model; if ((arrayCounter === 0)) { @@ -275,7 +276,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 +293,45 @@ 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); + } + 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[]): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: 'dc_type', + 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 dc_type = book OR dc_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); } From be7f21eb3225dba4eb5c6edec4fc096e05bd0ec7 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 17 Feb 2022 11:50:40 +1300 Subject: [PATCH 06/41] [TLC-254] Make the item type field configurable (default dc.type) --- config/config.example.yml | 5 +++++ src/app/shared/form/builder/form-builder.service.ts | 10 +++++++++- src/app/shared/form/builder/parsers/field-parser.ts | 12 ++++++++++-- src/config/default-app-config.ts | 3 +++ src/config/submission-config.interface.ts | 5 +++++ src/environments/environment.test.ts | 3 +++ 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 771c7b1653..fb0b4fd589 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -77,6 +77,11 @@ submission: # NOTE: after how many time (milliseconds) submission is saved automatically # eg. timer: 5 * (1000 * 60); // 5 minutes timer: 0 + typeBind: + # NOTE: which field to use when matching to type-bind configuration, + # eg. dc.type, local.publicationType + # default: dc.type + field: dc.type icons: metadata: # NOTE: example of configuration diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index 33703abf94..12d7585a1c 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -33,6 +33,7 @@ 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 { environment } from '../../../../environments/environment'; @Injectable() export class FormBuilderService extends DynamicFormService { @@ -49,6 +50,11 @@ export class FormBuilderService extends DynamicFormService { */ private formGroups: Map; + /** + * This is the field to use for type binding + */ + private typeField: string; + constructor( componentService: DynamicFormComponentService, validationService: DynamicFormValidationService, @@ -57,6 +63,8 @@ export class FormBuilderService extends DynamicFormService { super(componentService, validationService); this.formModels = new Map(); this.formGroups = new Map(); + // Replace . with _ in configured type field here, to make configuration more simple and user-friendly + this.typeField = environment.submission.typeBind.field.replace('\.', '_'); } createDynamicFormControlEvent(control: FormControl, group: FormGroup, model: DynamicFormControlModel, type: string): DynamicFormControlEvent { @@ -274,7 +282,7 @@ export class FormBuilderService extends DynamicFormService { } if (isNull(typeBindModel)) { - typeBindModel = this.findById('dc_type', rows); + typeBindModel = this.findById(this.typeField, rows); } if (typeBindModel !== null) { diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 838816ebb1..6e1c03efe0 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -17,6 +17,7 @@ import { RelationshipOptions } from '../models/relationship-options.model'; import { VocabularyOptions } from '../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { ParserType } from './parser-type'; import { isNgbDateStruct } from '../../../date.util'; +import { environment } from '../../../../../environments/environment'; export const SUBMISSION_ID: InjectionToken = new InjectionToken('submissionId'); export const CONFIG_DATA: InjectionToken = new InjectionToken('configData'); @@ -26,6 +27,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, @@ -33,6 +39,8 @@ export abstract class FieldParser { @Inject(INIT_FORM_VALUES) protected initFormValues: any, @Inject(PARSER_OPTIONS) protected parserOptions: ParserOptions ) { + // Replace . with _ in configured type field here, to make configuration more simple and user-friendly + this.typeField = environment.submission.typeBind.field.replace('\.', '_'); } public abstract modelFactory(fieldValue?: FormFieldMetadataValueObject, label?: boolean): any; @@ -315,14 +323,14 @@ export abstract class FieldParser { const bindValues = []; configuredTypeBindValues.forEach((value) => { bindValues.push({ - id: 'dc_type', + id: this.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 dc_type = book OR dc_type = book_part + // 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 [{ diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index dc54c2fcb0..7a4a5047ba 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -110,6 +110,9 @@ export class DefaultAppConfig implements AppConfig { */ timer: 0 }, + typeBind: { + field: 'dc.type' + }, icons: { metadata: [ /** 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 7c24ef8f05..95237f4b7c 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: [ { From 61d64d5e5ea0bbaa42117b1531cd76d9307e353e Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 17 Feb 2022 14:35:44 +1300 Subject: [PATCH 07/41] [TLC-254] Updating references in tests to reflect row array model changes --- .../ds-dynamic-form-control-container.component.spec.ts | 1 - src/app/shared/form/builder/form-builder.service.spec.ts | 7 +++++-- .../sections/form/section-form-operations.service.spec.ts | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) 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 785b0958d5..34d23ef719 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 @@ -150,7 +150,6 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { submissionId: '1234', id: 'relationGroup', formConfiguration: [], - isInlineGroup: false, mandatoryField: '', name: 'relationGroup', relationFields: [], 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..fe9d0a7bc6 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -197,7 +197,7 @@ describe('FormBuilderService test suite', () => { repeatable: false, metadataFields: [], submissionId: '1234', - hasSelectableMetadata: false + hasSelectableMetadata: false, }), new DynamicScrollableDropdownModel({ @@ -233,6 +233,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 +305,9 @@ describe('FormBuilderService test suite', () => { required: false, metadataKey: 'dc.contributor.author', metadataFields: ['dc.contributor.author'], - hasSelectableMetadata: true + hasSelectableMetadata: true, + showButtons: true, + typeBindRelations: [] }, ), ]; 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'); From f97a87702ccfa7f2cb95acb04b1a89a7403ca40f Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Fri, 25 Feb 2022 12:21:22 +1300 Subject: [PATCH 08/41] [TLC-254] Hidden fields can collapse in row, revert drag-handle problem apply d-none class to form-control container when model is hidden css rule for ds-form-control-container.d-none forces collapse drag-handle fix from 9019b809 was recent and is applied manually here too --- .../ds-dynamic-form-ui/ds-dynamic-form.component.html | 1 + .../models/array-group/dynamic-form-array.component.html | 5 +++-- .../models/form-group/dynamic-form-group.component.html | 1 + src/styles/_global-styles.scss | 7 +++++++ 4 files changed, 12 insertions(+), 2 deletions(-) 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/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 29df7a34c4..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 @@ -17,8 +17,8 @@ [cdkDragPreviewClass]="'ds-submission-reorder-dragging'" [class.grey-background]="model.isInlineGroupArray"> -
- +
+