From 1e9fbee0d59e004beb3a3fee7ca89c082ad774de Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 13 Feb 2026 17:51:48 +0100 Subject: [PATCH 01/12] [DURACOM-453] add edit item menu and data service --- src/app/app.menus.ts | 4 + src/app/core/data-services-map.ts | 2 + .../submission/edititem-data.service.spec.ts | 126 ++++++++++++++++++ .../core/submission/edititem-data.service.ts | 96 +++++++++++++ .../submission/models/edititem-mode.model.ts | 70 ++++++++++ .../core/submission/models/edititem.model.ts | 55 ++++++++ .../providers/edit-item-details.menu.spec.ts | 90 +++++++++++++ .../menu/providers/edit-item-details.menu.ts | 60 +++++++++ src/assets/i18n/en.json5 | 24 ++-- 9 files changed, 519 insertions(+), 8 deletions(-) create mode 100644 src/app/core/submission/edititem-data.service.spec.ts create mode 100644 src/app/core/submission/edititem-data.service.ts create mode 100644 src/app/core/submission/models/edititem-mode.model.ts create mode 100644 src/app/core/submission/models/edititem.model.ts create mode 100644 src/app/shared/menu/providers/edit-item-details.menu.spec.ts create mode 100644 src/app/shared/menu/providers/edit-item-details.menu.ts diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index e230b039719..48610f866a0 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -21,6 +21,7 @@ import { CurationMenuProvider } from './shared/menu/providers/curation.menu'; import { DSpaceObjectEditMenuProvider } from './shared/menu/providers/dso-edit.menu'; import { DsoOptionMenuProvider } from './shared/menu/providers/dso-option.menu'; import { EditMenuProvider } from './shared/menu/providers/edit.menu'; +import { EditItemDetailsMenuProvider } from './shared/menu/providers/edit-item-details.menu'; import { ExportMenuProvider } from './shared/menu/providers/export.menu'; import { HealthMenuProvider } from './shared/menu/providers/health.menu'; import { ImportMenuProvider } from './shared/menu/providers/import.menu'; @@ -104,6 +105,9 @@ export const MENUS = buildMenuStructure({ ClaimMenuProvider.onRoute( MenuRoute.ITEM_PAGE, ), + EditItemDetailsMenuProvider.onRoute( + MenuRoute.ITEM_PAGE, + ), ]), ], }); diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts index 77f39e080dd..216a5dfb7dd 100644 --- a/src/app/core/data-services-map.ts +++ b/src/app/core/data-services-map.ts @@ -58,6 +58,7 @@ import { VERSION } from './shared/version.resource-type'; import { VERSION_HISTORY } from './shared/version-history.resource-type'; import { USAGE_REPORT } from './statistics/models/usage-report.resource-type'; import { CorrectionType } from './submission/models/correctiontype.model'; +import { EditItem } from './submission/models/edititem.model'; import { SUBMISSION_CC_LICENSE } from './submission/models/submission-cc-licence.resource-type'; import { SUBMISSION_CC_LICENSE_URL } from './submission/models/submission-cc-licence-link.resource-type'; import { @@ -138,4 +139,5 @@ export const LAZY_DATA_SERVICES: LazyDataServicesMap = new Map([ [DUPLICATE.value, () => import('./submission/submission-duplicate-data.service').then(m => m.SubmissionDuplicateDataService)], [CorrectionType.type.value, () => import('./submission/correctiontype-data.service').then(m => m.CorrectionTypeDataService)], [AUDIT.value, () => import('./data/audit-data.service').then(m => m.AuditDataService)], + [EditItem.type.value, () => import('./submission/edititem-data.service').then(m => m.EditItemDataService)], ]); diff --git a/src/app/core/submission/edititem-data.service.spec.ts b/src/app/core/submission/edititem-data.service.spec.ts new file mode 100644 index 00000000000..78de27d50aa --- /dev/null +++ b/src/app/core/submission/edititem-data.service.spec.ts @@ -0,0 +1,126 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { TestBed } from '@angular/core/testing'; +import { cold } from 'jasmine-marbles'; +import { of } from 'rxjs'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RequestService } from '../data/request.service'; +import { NotificationsService } from '../notification-system/notifications.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { createSuccessfulRemoteDataObject } from '../utilities/remote-data.utils'; +import { EditItemDataService } from './edititem-data.service'; +import { EditItemMode } from './models/edititem-mode.model'; + +describe('EditItemDataService', () => { + + let service: EditItemDataService; + let requestService: RequestService; + + const requestServiceStub = jasmine.createSpyObj('RequestService', [ + 'setStaleByHrefSubstring', + ]); + + const rdbServiceStub = {} as RemoteDataBuildService; + const objectCacheStub = {} as ObjectCacheService; + const halServiceStub = {} as HALEndpointService; + const notificationsServiceStub = {} as NotificationsService; + + const editModes: EditItemMode[] = [ + { uuid: 'mode-1', name: 'quickedit' } as EditItemMode, + { uuid: 'mode-2', name: 'full' } as EditItemMode, + ]; + + const paginatedList = { + page: editModes, + } as PaginatedList; + + const successfulRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + + TestBed.configureTestingModule({ + providers: [ + EditItemDataService, + { provide: RequestService, useValue: requestServiceStub }, + { provide: RemoteDataBuildService, useValue: rdbServiceStub }, + { provide: ObjectCacheService, useValue: objectCacheStub }, + { provide: HALEndpointService, useValue: halServiceStub }, + { provide: NotificationsService, useValue: notificationsServiceStub }, + ], + }); + + service = TestBed.inject(EditItemDataService); + + spyOn((service as any).searchData, 'searchBy') + .and.returnValue(of(successfulRD)); + }); + + afterEach(() => { + service = null; + }); + + + describe('searchEditModesById', () => { + + it('should call SearchDataImpl.searchBy with correct parameters', () => { + + service.searchEditModesById('test-id').subscribe(); + + expect((service as any).searchData.searchBy) + .toHaveBeenCalled(); + }); + + it('should return edit modes', () => { + + const result = service.searchEditModesById('test-id'); + + const expected = cold('(a|)', { a: successfulRD }); + + expect(result).toBeObservable(expected); + }); + + }); + + describe('checkEditModeByIdAndType', () => { + + it('should return TRUE when edit mode exists', () => { + + const result = service.checkEditModeByIdAndType('test-id', 'mode-1'); + + const expected = cold('(a|)', { a: true }); + + expect(result).toBeObservable(expected); + }); + + it('should return FALSE when edit mode does not exist', () => { + + const result = service.checkEditModeByIdAndType('test-id', 'unknown-mode'); + + const expected = cold('(a|)', { a: false }); + + expect(result).toBeObservable(expected); + }); + + }); + + describe('invalidateItemCache', () => { + + it('should mark requests as stale', () => { + + service.invalidateItemCache('1234'); + + expect(requestServiceStub.setStaleByHrefSubstring) + .toHaveBeenCalledWith('findModesById?uuid=1234'); + }); + + }); + +}); diff --git a/src/app/core/submission/edititem-data.service.ts b/src/app/core/submission/edititem-data.service.ts new file mode 100644 index 00000000000..8f63c6477aa --- /dev/null +++ b/src/app/core/submission/edititem-data.service.ts @@ -0,0 +1,96 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DeleteDataImpl } from '../data/base/delete-data'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { SearchDataImpl } from '../data/base/search-data'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { NotificationsService } from '../notification-system/notifications.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + getAllSucceededRemoteDataPayload, + getPaginatedListPayload, +} from '../shared/operators'; +import { EditItem } from './models/edititem.model'; +import { EditItemMode } from './models/edititem-mode.model'; + +/** + * A service that provides methods to make REST requests with edititems endpoint. + */ +@Injectable({ providedIn: 'root' }) +export class EditItemDataService extends IdentifiableDataService { + protected linkPath = 'edititems'; + protected searchById = 'findModesById'; + private searchData: SearchDataImpl; + private deleteData: DeleteDataImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('edititems', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + } + + /** + * Search for editModes from the editItem id + * + * @param id string id of edit item + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @return Paginated list of edit item modes + */ + searchEditModesById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable>> { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', id, false), + ]; + return this.searchData.searchBy(this.searchById, options, useCachedVersionIfAvailable, reRequestOnStale); + } + + /** + * Check if editMode with id is part of the edit item with id + * + * @param id string id of edit item + * @param editModeId string id of edit item + * @return boolean + */ + checkEditModeByIdAndType(id: string, editModeId: string) { + return this.searchEditModesById(id).pipe( + getAllSucceededRemoteDataPayload(), + getPaginatedListPayload(), + map((editModes: EditItemMode[]) => { + return !!editModes.find(editMode => editMode.uuid === editModeId); + })); + } + + /** + * Invalidate the cache of the editMode + * @param id + */ + invalidateItemCache(id: string) { + this.requestService.setStaleByHrefSubstring('findModesById?uuid=' + id); + } + +} diff --git a/src/app/core/submission/models/edititem-mode.model.ts b/src/app/core/submission/models/edititem-mode.model.ts new file mode 100644 index 00000000000..b52d4825acc --- /dev/null +++ b/src/app/core/submission/models/edititem-mode.model.ts @@ -0,0 +1,70 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + autoserialize, + deserialize, + deserializeAs, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; + +/** + * Describes a EditItem mode + */ +@typedObject +export class EditItemMode extends CacheableObject { + + static type = new ResourceType('edititemmode'); + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The universally unique identifier of this WorkspaceItem + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer(EditItemMode.type.value), 'name') + uuid: string; + + /** + * Name of the EditItem Mode + */ + @autoserialize + name: string; + + /** + * Label used for i18n + */ + @autoserialize + label: string; + + /** + * Name of the Submission Definition used + * for this EditItem mode + */ + @autoserialize + submissionDefinition: string; + + /** + * The {@link HALLink}s for this EditItemMode + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/core/submission/models/edititem.model.ts b/src/app/core/submission/models/edititem.model.ts new file mode 100644 index 00000000000..cac079ff3a3 --- /dev/null +++ b/src/app/core/submission/models/edititem.model.ts @@ -0,0 +1,55 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + deserializeAs, + inheritSerialization, +} from 'cerialize'; +import { Observable } from 'rxjs'; + +import { + inheritLinkAnnotations, + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { ResourceType } from '../../shared/resource-type'; +import { EditItemMode } from './edititem-mode.model'; +import { SubmissionObject } from './submission-object.model'; + +/** + * A model class for a EditItem. + */ +@typedObject +@inheritSerialization(SubmissionObject) +@inheritLinkAnnotations(SubmissionObject) +export class EditItem extends SubmissionObject { + static type = new ResourceType('edititem'); + + /** + * The universally unique identifier of this WorkspaceItem + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer(EditItem.type.value), 'id') + uuid: string; + + /** + * Existing EditItem modes for current EditItem + * Will be undefined unless the modes {@link HALLink} has been resolved. + */ + @link(EditItemMode.type) + modes?: Observable>>; + /** + * Existing EditItem modes for current EditItem + * Will be undefined unless the modes {@link HALLink} has been resolved. + */ + @link(EditItemMode.type) + edititemmodes?: Observable>>; +} diff --git a/src/app/shared/menu/providers/edit-item-details.menu.spec.ts b/src/app/shared/menu/providers/edit-item-details.menu.spec.ts new file mode 100644 index 00000000000..a814f843d35 --- /dev/null +++ b/src/app/shared/menu/providers/edit-item-details.menu.spec.ts @@ -0,0 +1,90 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TestBed } from '@angular/core/testing'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { EditItemDataService } from '@dspace/core/submission/edititem-data.service'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { cold } from 'jasmine-marbles'; + +import { MenuItemType } from '../menu-item-type.model'; +import { EditItemDetailsMenuProvider } from './edit-item-details.menu'; + +describe('EditItemDetailsMenuProvider', () => { + + let provider: EditItemDetailsMenuProvider; + + const editItemServiceStub = jasmine.createSpyObj('EditItemDataService', [ + 'searchEditModesById', + ]); + + const mockDSO = { + id: 'test-id', + uuid: 'test-uuid', + } as DSpaceObject; + + const mockEditModes = [ + { name: 'quickedit' }, + { name: 'full' }, + ]; + + const expectedSections = [ + { + model: { + type: MenuItemType.LINK, + text: 'menu.section.quickedit', + link: jasmine.any(String), + }, + visible: true, + }, + { + model: { + type: MenuItemType.LINK, + text: 'menu.section.full', + link: jasmine.any(String), + }, + visible: true, + }, + ]; + + beforeEach(() => { + + editItemServiceStub.searchEditModesById.and.returnValue( + createSuccessfulRemoteDataObject$( + createPaginatedList(mockEditModes), + ), + ); + + TestBed.configureTestingModule({ + providers: [ + EditItemDetailsMenuProvider, + { provide: EditItemDataService, useValue: editItemServiceStub }, + ], + }); + + provider = TestBed.inject(EditItemDetailsMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + it('should return menu sections', () => { + + const result = provider.getSectionsForContext(mockDSO); + + const expected = cold('(ab|)', { + a: [], + b: expectedSections, + }); + + expect(result).toBeObservable(expected); + }); + +}); diff --git a/src/app/shared/menu/providers/edit-item-details.menu.ts b/src/app/shared/menu/providers/edit-item-details.menu.ts new file mode 100644 index 00000000000..422026e8f96 --- /dev/null +++ b/src/app/shared/menu/providers/edit-item-details.menu.ts @@ -0,0 +1,60 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Injectable } from '@angular/core'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { + getAllSucceededRemoteDataPayload, + getPaginatedListPayload, +} from '@dspace/core/shared/operators'; +import { EditItemDataService } from '@dspace/core/submission/edititem-data.service'; +import { EditItemMode } from '@dspace/core/submission/models/edititem-mode.model'; +import { URLCombiner } from '@dspace/core/url-combiner/url-combiner'; +import { + map, + Observable, +} from 'rxjs'; +import { startWith } from 'rxjs/operators'; + +import { getEditItemPageRoute } from '../../../app-routing-paths'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; + +/** + * Menu provider to create the "Edit details" option in the DSO audit menu + */ +@Injectable() +export class EditItemDetailsMenuProvider extends DSpaceObjectPageMenuProvider { + constructor( + protected editItemService: EditItemDataService, + ) { + super(); + } + + + public getSectionsForContext(dso: DSpaceObject): Observable { + return this.editItemService.searchEditModesById(dso.id).pipe( + getAllSucceededRemoteDataPayload(), + getPaginatedListPayload(), + startWith([]), + map((editModes: EditItemMode[]) => { + return editModes.map(editMode => { + return { + model: { + type: MenuItemType.LINK, + text: `menu.section.${editMode.name}`, + link: new URLCombiner(getEditItemPageRoute(), `${dso.uuid}:${editMode.name}`).toString(), + }, + visible: true, + }; + }); + }), + ); + } + +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index a02dd9007ca..563c60ee03d 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2965,7 +2965,15 @@ "item.page.date": "Date", - "item.page.edit": "Edit this item", + "item.page.edit": "Administer", + + "item.page.edit.FULL": "Edit all the details", + + "item.page.edit.OWNER": "Edit", + + "item.page.edit.DIRECTOR": "Edit", + + "item.page.edit.INVESTIGATOR": "Edit", "item.page.files": "Files", @@ -3343,7 +3351,7 @@ "journal.page.description": "Description", - "journal.page.edit": "Edit this item", + "journal.page.edit": "Administer", "journal.page.editor": "Editor-in-Chief", @@ -3365,7 +3373,7 @@ "journalissue.page.description": "Description", - "journalissue.page.edit": "Edit this item", + "journalissue.page.edit": "Administer", "journalissue.page.issuedate": "Issue Date", @@ -3387,7 +3395,7 @@ "journalvolume.page.description": "Description", - "journalvolume.page.edit": "Edit this item", + "journalvolume.page.edit": "Administer", "journalvolume.page.issuedate": "Issue Date", @@ -3953,7 +3961,7 @@ "orgunit.page.description": "Description", - "orgunit.page.edit": "Edit this item", + "orgunit.page.edit": "Administer", "orgunit.page.id": "ID", @@ -3981,7 +3989,7 @@ "person.page.birthdate": "Birth Date", - "person.page.edit": "Edit this item", + "person.page.edit": "Administer", "person.page.email": "Email Address", @@ -4237,7 +4245,7 @@ "project.page.description": "Description", - "project.page.edit": "Edit this item", + "project.page.edit": "Administer", "project.page.expectedcompletion": "Expected Completion", @@ -4261,7 +4269,7 @@ "publication.page.description": "Description", - "publication.page.edit": "Edit this item", + "publication.page.edit": "Administer", "publication.page.journal-issn": "Journal ISSN", From a3c14c5fb66ebd7a093d7bb9f1e922a9c8701ae7 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Mon, 16 Feb 2026 15:26:49 +0100 Subject: [PATCH 02/12] [DURACOM-453] finalize improvement for submission and edit mode --- src/app/app-routes.ts | 6 + src/app/core/data/remote-data.ts | 2 + src/app/core/data/response-state.model.ts | 9 ++ .../models/submission-section-object.model.ts | 5 + .../submission/submission-rest.service.ts | 11 +- .../core/submission/submission-scope-type.ts | 1 + .../core/testing/submission-service.stub.ts | 1 + src/app/core/utilities/remote-data.utils.ts | 4 +- src/app/edit-item/edit-item-routes.ts | 21 +++ ...c-form-control-container.component.spec.ts | 4 +- .../pending-changes.guard.spec.ts | 82 ++++++++++ .../pending-changes/pending-changes.guard.ts | 43 +++++ ...ission-edit-can-deactivate.service.spec.ts | 98 +++++++++++ .../submission-edit-can-deactivate.service.ts | 38 +++++ .../edit/submission-edit.component.ts | 1 - .../objects/submission-objects.actions.ts | 97 ++++++++++- .../submission-objects.effects.spec.ts | 153 +++++++++++++++--- .../objects/submission-objects.effects.ts | 139 ++++++++++++++-- .../submission-objects.reducer.spec.ts | 37 ++++- .../objects/submission-objects.reducer.ts | 122 +++++++++++++- .../submission-object.service.spec.ts | 7 + .../submission/submission-object.service.ts | 17 +- src/app/submission/submission.service.spec.ts | 53 +++++- src/app/submission/submission.service.ts | 55 ++++++- src/app/submission/utils/submission.mock.ts | 14 ++ src/assets/i18n/en.json5 | 24 ++- 26 files changed, 977 insertions(+), 67 deletions(-) create mode 100644 src/app/edit-item/edit-item-routes.ts create mode 100644 src/app/submission/edit/pending-changes/pending-changes.guard.spec.ts create mode 100644 src/app/submission/edit/pending-changes/pending-changes.guard.ts create mode 100644 src/app/submission/edit/submission-edit-can-deactivate.service.spec.ts create mode 100644 src/app/submission/edit/submission-edit-can-deactivate.service.ts diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 3ccabfdb212..a769f67ae91 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -28,6 +28,7 @@ import { ACCESS_CONTROL_MODULE_PATH } from './access-control/access-control-rout import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; import { ADMIN_MODULE_PATH, + EDIT_ITEM_PATH, FORGOT_PASSWORD_PATH, HEALTH_PAGE_PATH, PROFILE_MODULE_PATH, @@ -279,6 +280,11 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [authenticatedGuard], }, + { + path: EDIT_ITEM_PATH, + loadChildren: () => import('./edit-item/edit-item-routes').then((m) => m.ROUTES), + canActivate: [endUserAgreementCurrentUserGuard], + }, { path: 'external-login/:token', loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES), diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index 935d2a95d27..5ea4f612308 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -12,6 +12,7 @@ import { isSuccessStale, RequestEntryState, } from './request-entry-state.model'; +import { PathableObjectError } from './response-state.model'; /** * A class to represent the state of a remote resource @@ -25,6 +26,7 @@ export class RemoteData { public errorMessage?: string, public payload?: T, public statusCode?: number, + public errors?: PathableObjectError[], ) { } diff --git a/src/app/core/data/response-state.model.ts b/src/app/core/data/response-state.model.ts index 1f7863e9c8a..71b97043801 100644 --- a/src/app/core/data/response-state.model.ts +++ b/src/app/core/data/response-state.model.ts @@ -1,6 +1,14 @@ import { HALLink } from '../shared/hal-link.model'; import { UnCacheableObject } from '../shared/uncacheable-object.model'; +/** + * Interface for rest error associated to a path + */ +export interface PathableObjectError { + message: string; + paths: string[]; +} + /** * The response substate in the NgRx store */ @@ -8,6 +16,7 @@ export class ResponseState { timeCompleted: number; statusCode: number; errorMessage?: string; + errors?: PathableObjectError[]; payloadLink?: HALLink; unCacheableObject?: UnCacheableObject; } diff --git a/src/app/core/submission/models/submission-section-object.model.ts b/src/app/core/submission/models/submission-section-object.model.ts index fdce5d9e7a9..8409f2bc980 100644 --- a/src/app/core/submission/models/submission-section-object.model.ts +++ b/src/app/core/submission/models/submission-section-object.model.ts @@ -75,6 +75,11 @@ export interface SubmissionSectionObject { */ isLoading: boolean; + /** + * A boolean representing if this section removal is pending + */ + removePending: boolean; + /** * A boolean representing if this section is valid */ diff --git a/src/app/core/submission/submission-rest.service.ts b/src/app/core/submission/submission-rest.service.ts index 12f94a02d2b..897882d8d17 100644 --- a/src/app/core/submission/submission-rest.service.ts +++ b/src/app/core/submission/submission-rest.service.ts @@ -91,10 +91,15 @@ export class SubmissionRestService { * The identifier for the object * @param collectionId * The owning collection for the object + * @param projections */ - protected getEndpointByIDHref(endpoint, resourceID, collectionId?: string): string { + protected getEndpointByIDHref(endpoint, resourceID, collectionId?: string, projections: string[] = []): string { let url = isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; url = new URLCombiner(url, '?embed=item,sections,collection').toString(); + + projections.forEach((projection) => { + url = new URLCombiner(url, '&projection=' + projection).toString(); + }); if (collectionId) { url = new URLCombiner(url, `&owningCollection=${collectionId}`).toString(); } @@ -135,9 +140,9 @@ export class SubmissionRestService { * @return Observable * server response */ - public getDataById(linkName: string, id: string, useCachedVersionIfAvailable = false): Observable { + public getDataById(linkName: string, id: string, useCachedVersionIfAvailable = false, projections: string[] = []): Observable { return this.halService.getEndpoint(linkName).pipe( - map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id, null, projections)), filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), mergeMap((endpointURL: string) => { diff --git a/src/app/core/submission/submission-scope-type.ts b/src/app/core/submission/submission-scope-type.ts index f319e5c473e..683472370d4 100644 --- a/src/app/core/submission/submission-scope-type.ts +++ b/src/app/core/submission/submission-scope-type.ts @@ -1,4 +1,5 @@ export enum SubmissionScopeType { WorkspaceItem = 'WORKSPACE', WorkflowItem = 'WORKFLOW', + EditItem = 'EDIT' } diff --git a/src/app/core/testing/submission-service.stub.ts b/src/app/core/testing/submission-service.stub.ts index d9d28bde0ee..da8de737113 100644 --- a/src/app/core/testing/submission-service.stub.ts +++ b/src/app/core/testing/submission-service.stub.ts @@ -25,6 +25,7 @@ export class SubmissionServiceStub { isSubmissionLoading = jasmine.createSpy('isSubmissionLoading'); notifyNewSection = jasmine.createSpy('notifyNewSection'); redirectToMyDSpace = jasmine.createSpy('redirectToMyDSpace'); + redirectToItemPage = jasmine.createSpy('redirectToItemPage'); resetAllSubmissionObjects = jasmine.createSpy('resetAllSubmissionObjects'); resetSubmissionObject = jasmine.createSpy('resetSubmissionObject'); retrieveSubmission = jasmine.createSpy('retrieveSubmission'); diff --git a/src/app/core/utilities/remote-data.utils.ts b/src/app/core/utilities/remote-data.utils.ts index fc4e13f06b6..237bb2a5614 100644 --- a/src/app/core/utilities/remote-data.utils.ts +++ b/src/app/core/utilities/remote-data.utils.ts @@ -6,6 +6,7 @@ import { import { RemoteData } from '../data/remote-data'; import { RequestEntryState } from '../data/request-entry-state.model'; +import { PathableObjectError } from '../data/response-state.model'; /** * A fixed timestamp to use in tests @@ -45,7 +46,7 @@ export function createSuccessfulRemoteDataObject$(object: T, timeCompleted?: * @param statusCode the status code * @param timeCompleted the moment when the remoteData was completed */ -export function createFailedRemoteDataObject(errorMessage?: string, statusCode?: number, timeCompleted = 1577836800000): RemoteData { +export function createFailedRemoteDataObject(errorMessage?: string, statusCode?: number, timeCompleted = 1577836800000, errors: PathableObjectError[] = []): RemoteData { return new RemoteData( timeCompleted, 15 * 60 * 1000, @@ -54,6 +55,7 @@ export function createFailedRemoteDataObject(errorMessage?: string, statusCod errorMessage, undefined, statusCode, + errors, ); } diff --git a/src/app/edit-item/edit-item-routes.ts b/src/app/edit-item/edit-item-routes.ts new file mode 100644 index 00000000000..cf4bdb43b93 --- /dev/null +++ b/src/app/edit-item/edit-item-routes.ts @@ -0,0 +1,21 @@ +import { Route } from '@angular/router'; + +import { authenticatedGuard } from '../core/auth/authenticated.guard'; +import { pendingChangesGuard } from '../submission/edit/pending-changes/pending-changes.guard'; +import { ThemedSubmissionEditComponent } from '../submission/edit/themed-submission-edit.component'; + +export const ROUTES: Route[] = [ + { + path: ':id', + runGuardsAndResolvers: 'always', + children: [ + { + path: '', + canActivate: [authenticatedGuard], + canDeactivate: [pendingChangesGuard], + component: ThemedSubmissionEditComponent, + data: { title: 'submission.edit.title' }, + }, + ], + }, +]; 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 60434caac87..010eddd9e81 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 @@ -415,7 +415,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { it('should call announceErrorMessages on SAVE_SUBMISSION_FORM_ERROR', () => { spyOn(component, 'announceErrorMessages'); - actions$.next(new SaveSubmissionFormErrorAction('1234')); + actions$.next(new SaveSubmissionFormErrorAction('1234', null, null)); expect(component.announceErrorMessages).toHaveBeenCalled(); }); @@ -427,7 +427,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { it('should call announceErrorMessages on SAVE_SUBMISSION_SECTION_FORM_ERROR', () => { spyOn(component, 'announceErrorMessages'); - actions$.next(new SaveSubmissionSectionFormErrorAction('1234')); + actions$.next(new SaveSubmissionSectionFormErrorAction('1234', null, null)); expect(component.announceErrorMessages).toHaveBeenCalled(); }); }); diff --git a/src/app/submission/edit/pending-changes/pending-changes.guard.spec.ts b/src/app/submission/edit/pending-changes/pending-changes.guard.spec.ts new file mode 100644 index 00000000000..8e456a7e370 --- /dev/null +++ b/src/app/submission/edit/pending-changes/pending-changes.guard.spec.ts @@ -0,0 +1,82 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of, +} from 'rxjs'; + +import { SubmissionEditCanDeactivateService } from '../submission-edit-can-deactivate.service'; +import { pendingChangesGuard } from './pending-changes.guard'; +import SpyObj = jasmine.SpyObj; + +describe('pendingChangesGuard', () => { + + let modalService: SpyObj; + + const modalStub: any = { + componentInstance: { + headerLabel: 'headerLabel', + infoLabel: 'infoLabel', + cancelLabel: 'cancelLabel', + confirmLabel: 'confirmLabel', + brandColor: 'brandColor', + confirmIcon: 'confirmIcon', + response: of(true), + }, + }; + + const canDeactivateServiceSpy = jasmine.createSpyObj('canDeactivateService', ['canDeactivate']); + + const modalServiceSpy = jasmine.createSpyObj('modalService', { + open: jasmine.createSpy('open'), + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + { provide: NgbModal, useValue: modalServiceSpy }, + { provide: SubmissionEditCanDeactivateService, useValue: canDeactivateServiceSpy }, + ], + }); + modalService = TestBed.inject(NgbModal) as SpyObj; + modalService.open.and.returnValue(modalStub); + })); + + it('should be created', () => { + expect(pendingChangesGuard).toBeTruthy(); + }); + + describe('when there are unsaved changes', () => { + beforeEach(() => { + canDeactivateServiceSpy.canDeactivate.and.returnValue(of(false)); + }); + it('should open confirmation modal', () => { + const result$ = TestBed.runInInjectionContext(() => { + return pendingChangesGuard({ params: { id: 'test-id' } } as any, null, null, null); + }) as Observable; + result$.subscribe(() => { + expect(modalService.open).toHaveBeenCalled(); + }); + }); + }); + + describe('when there are not unsaved changes', () => { + beforeEach(() => { + canDeactivateServiceSpy.canDeactivate.and.returnValue(of(true)); + }); + it('should allow navigation', () => { + TestBed.runInInjectionContext(() => { + const result = pendingChangesGuard({ params: { id: 'test-id' } } as any, null, null, null); + const expected = cold('(a|)', { + a: true, + }); + expect(result).toBeObservable(expected); + }); + }); + }); +}); diff --git a/src/app/submission/edit/pending-changes/pending-changes.guard.ts b/src/app/submission/edit/pending-changes/pending-changes.guard.ts new file mode 100644 index 00000000000..a767814a4e7 --- /dev/null +++ b/src/app/submission/edit/pending-changes/pending-changes.guard.ts @@ -0,0 +1,43 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanDeactivateFn, + RouterStateSnapshot, +} from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + Observable, + of, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; +import { SubmissionEditCanDeactivateService } from '../submission-edit-can-deactivate.service'; +import { ThemedSubmissionEditComponent } from '../themed-submission-edit.component'; + +export const pendingChangesGuard: CanDeactivateFn = ( + component: ThemedSubmissionEditComponent, + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +): Observable => { + const modalService = inject(NgbModal); + const canDeactivateService = inject(SubmissionEditCanDeactivateService); + + return canDeactivateService.canDeactivate(route?.params?.id).pipe( + switchMap((canDeactivate) => { + if (canDeactivate) { + return of(true); + } else { + const modalRef = modalService.open(ConfirmationModalComponent); + const labelPrefix = 'confirmation-modal.pending-changes.'; + modalRef.componentInstance.headerLabel = labelPrefix + 'header'; + modalRef.componentInstance.infoLabel = labelPrefix + 'info'; + modalRef.componentInstance.cancelLabel = labelPrefix + 'cancel'; + modalRef.componentInstance.confirmLabel = labelPrefix + 'confirm'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + return modalRef.componentInstance.response as Observable; + } + }), + ); +}; diff --git a/src/app/submission/edit/submission-edit-can-deactivate.service.spec.ts b/src/app/submission/edit/submission-edit-can-deactivate.service.spec.ts new file mode 100644 index 00000000000..6076aeb827d --- /dev/null +++ b/src/app/submission/edit/submission-edit-can-deactivate.service.spec.ts @@ -0,0 +1,98 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { cold } from 'jasmine-marbles'; +import { of } from 'rxjs'; + +import { SubmissionService } from '../submission.service'; +import { SubmissionEditCanDeactivateService } from './submission-edit-can-deactivate.service'; + +describe('SubmissionEditCanDeactivateService', () => { + let service: SubmissionEditCanDeactivateService; + let submissionService: SubmissionService; + + const submissionServiceSpy = jasmine.createSpyObj('submissionService', ['hasUnsavedModification', 'isSubmissionDiscarding']); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + { provide: ActivatedRoute, useValue: {} }, + { provide: Router, useValue: {} }, + { provide: SubmissionService, useValue: submissionServiceSpy }, + ], + }); + service = TestBed.inject(SubmissionEditCanDeactivateService); + submissionService = TestBed.inject(SubmissionService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('when submission is discarding', () => { + beforeEach(() => { + submissionServiceSpy.isSubmissionDiscarding.and.returnValue(of(true)); + }); + + describe('and there are unsaved changes', () => { + beforeEach(() => { + submissionServiceSpy.hasUnsavedModification.and.returnValue(of(true)); + }); + it('canDeactivate() should return true', () => { + const result = service.canDeactivate('id'); + const expected = cold('(a|)', { + a: true, + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('and there are not unsaved changes', () => { + beforeEach(() => { + submissionServiceSpy.hasUnsavedModification.and.returnValue(of(false)); + }); + it('canDeactivate() should return true', () => { + const result = service.canDeactivate('id'); + const expected = cold('(a|)', { + a: true, + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('when submission is not discarding', () => { + beforeEach(() => { + submissionServiceSpy.isSubmissionDiscarding.and.returnValue(of(false)); + }); + + describe('and there are unsaved changes', () => { + beforeEach(() => { + submissionServiceSpy.hasUnsavedModification.and.returnValue(of(true)); + }); + it('canDeactivate() should return false', () => { + const result = service.canDeactivate('id'); + const expected = cold('(a|)', { + a: false, + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('and there are not unsaved changes', () => { + beforeEach(() => { + submissionServiceSpy.hasUnsavedModification.and.returnValue(of(false)); + }); + it('canDeactivate() should return true', () => { + const result = service.canDeactivate('id'); + const expected = cold('(a|)', { + a: true, + }); + expect(result).toBeObservable(expected); + }); + }); + }); +}); diff --git a/src/app/submission/edit/submission-edit-can-deactivate.service.ts b/src/app/submission/edit/submission-edit-can-deactivate.service.ts new file mode 100644 index 00000000000..bac092ede21 --- /dev/null +++ b/src/app/submission/edit/submission-edit-can-deactivate.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + combineLatest, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { SubmissionService } from '../submission.service'; + +interface PendingChangesGuardComponentInterface { + canDeactivate: (id: string) => boolean | Observable; +} + +@Injectable({ + providedIn: 'root', +}) +export class SubmissionEditCanDeactivateService implements PendingChangesGuardComponentInterface { + + constructor( + private route: ActivatedRoute, + private router: Router, + private submissionService: SubmissionService, + ) { } + + public canDeactivate(id: string): Observable { + return combineLatest([ + this.submissionService.isSubmissionDiscarding(id), + this.submissionService.hasUnsavedModification(), + ]).pipe( + map(([isSubmissionDiscarding,hasUnsavedModification]) => isSubmissionDiscarding || !hasUnsavedModification), + ); + } + +} diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 9eeb6aecd5e..786def46ab2 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -169,7 +169,6 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { // redirect to not found page this.router.navigate(['/404'], { skipLocationChange: true }); } - // TODO handle generic error } }), this.itemLink$.pipe( diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index af302478ef6..d66a3172d05 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -46,10 +46,13 @@ export const SubmissionObjectActionTypes = { ENABLE_SECTION: type('dspace/submission/ENABLE_SECTION'), DISABLE_SECTION: type('dspace/submission/DISABLE_SECTION'), SET_SECTION_FORM_ID: type('dspace/submission/SET_SECTION_FORM_ID'), + DISABLE_SECTION_SUCCESS: type('dspace/submission/DISABLE_SECTION_SUCCESS'), + DISABLE_SECTION_ERROR: type('dspace/submission/DISABLE_SECTION_ERROR'), SECTION_STATUS_CHANGE: type('dspace/submission/SECTION_STATUS_CHANGE'), SECTION_LOADING_STATUS_CHANGE: type('dspace/submission/SECTION_LOADING_STATUS_CHANGE'), UPDATE_SECTION_DATA: type('dspace/submission/UPDATE_SECTION_DATA'), UPDATE_SECTION_DATA_SUCCESS: type('dspace/submission/UPDATE_SECTION_DATA_SUCCESS'), + UPDATE_SECTION_ERRORS: type('dspace/submission/UPDATE_SECTION_ERRORS'), SAVE_AND_DEPOSIT_SUBMISSION: type('dspace/submission/SAVE_AND_DEPOSIT_SUBMISSION'), DEPOSIT_SUBMISSION: type('dspace/submission/DEPOSIT_SUBMISSION'), DEPOSIT_SUBMISSION_SUCCESS: type('dspace/submission/DEPOSIT_SUBMISSION_SUCCESS'), @@ -212,6 +215,46 @@ export class DisableSectionAction implements Action { } } +export class DisableSectionSuccessAction implements Action { + type = SubmissionObjectActionTypes.DISABLE_SECTION_SUCCESS; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new DisableSectionSuccessAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to remove + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + +export class DisableSectionErrorAction implements Action { + type = SubmissionObjectActionTypes.DISABLE_SECTION_ERROR; + payload: { + submissionId: string; + sectionId: string; + }; + + /** + * Create a new DisableSectionErrorAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to remove + */ + constructor(submissionId: string, sectionId: string) { + this.payload = { submissionId, sectionId }; + } +} + export class UpdateSectionDataAction implements Action { type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA; payload: { @@ -268,6 +311,35 @@ export class CleanDuplicateDetectionAction implements Action { } } +export class UpdateSectionErrorsAction implements Action { + type = SubmissionObjectActionTypes.UPDATE_SECTION_ERRORS; + payload: { + submissionId: string; + sectionId: string; + errorsToShow: SubmissionSectionError[]; + serverValidationErrors: SubmissionSectionError[]; + }; + + /** + * Create a new EnableSectionAction + * + * @param submissionId + * the submission's ID to remove + * @param sectionId + * the section's ID to add + * @param errorsToShow + * the list of the section's errors to show + * @param serverValidationErrors + * the list of the section errors detected by the server + */ + constructor(submissionId: string, + sectionId: string, + errorsToShow: SubmissionSectionError[], + serverValidationErrors: SubmissionSectionError[]) { + this.payload = { submissionId, sectionId, errorsToShow, serverValidationErrors }; + } +} + export class UpdateSectionDataSuccessAction implements Action { type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA_SUCCESS; } @@ -475,6 +547,8 @@ export class SaveSubmissionFormErrorAction implements Action { type = SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR; payload: { submissionId: string; + statusCode: number; + errorMessage: string; }; /** @@ -482,9 +556,13 @@ export class SaveSubmissionFormErrorAction implements Action { * * @param submissionId * the submission's ID + * @param statusCode + * the submission's response error code + * @param errorMessage + * the submission's response error message */ - constructor(submissionId: string) { - this.payload = { submissionId }; + constructor(submissionId: string, statusCode: number, errorMessage: string) { + this.payload = { submissionId, statusCode, errorMessage }; } } @@ -533,6 +611,8 @@ export class SaveSubmissionSectionFormErrorAction implements Action { type = SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR; payload: { submissionId: string; + statusCode: number; + errorMessage: string; }; /** @@ -540,9 +620,13 @@ export class SaveSubmissionSectionFormErrorAction implements Action { * * @param submissionId * the submission's ID + * @param statusCode + * the submission's response error code + * @param errorMessage + * the submission's response error message */ - constructor(submissionId: string) { - this.payload = { submissionId }; + constructor(submissionId: string, statusCode: number, errorMessage: string) { + this.payload = { submissionId, statusCode, errorMessage }; } } @@ -867,6 +951,8 @@ export class DeleteUploadedFileAction implements Action { * so that reducers can easily compose action types */ export type SubmissionObjectAction = DisableSectionAction + | DisableSectionSuccessAction + | DisableSectionErrorAction | InitSectionAction | SetSectionFormId | EnableSectionAction @@ -901,4 +987,5 @@ export type SubmissionObjectAction = DisableSectionAction | SaveSubmissionSectionFormAction | SaveSubmissionSectionFormSuccessAction | SaveSubmissionSectionFormErrorAction - | SetActiveSectionAction; + | SetActiveSectionAction + | UpdateSectionErrorsAction; diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index 6d38cbd9ae4..6bac48c65e1 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -3,9 +3,12 @@ import { SubmissionSectionModel } from '@dspace/core/config/models/config-submis import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; import { Item } from '@dspace/core/shared/item.model'; +import { EditItemDataService } from '@dspace/core/submission/edititem-data.service'; import { SubmissionJsonPatchOperationsService } from '@dspace/core/submission/submission-json-patch-operations.service'; +import { SubmissionScopeType } from '@dspace/core/submission/submission-scope-type'; import { WorkflowItemDataService } from '@dspace/core/submission/workflowitem-data.service'; import { WorkspaceitemDataService } from '@dspace/core/submission/workspaceitem-data.service'; +import { HALEndpointServiceStub } from '@dspace/core/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '@dspace/core/testing/notifications-service.stub'; import { SectionsServiceStub } from '@dspace/core/testing/sections-service.stub'; import { StoreMock } from '@dspace/core/testing/store.mock'; @@ -13,6 +16,7 @@ import { SubmissionJsonPatchOperationsServiceStub } from '@dspace/core/testing/s import { mockSubmissionObjectDataService } from '@dspace/core/testing/submission-oject-data-service.mock'; import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createFailedRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { provideMockActions } from '@ngrx/effects/testing'; import { Store, @@ -25,12 +29,14 @@ import { } from '@ngx-translate/core'; import { cold, + getTestScheduler, hot, } from 'jasmine-marbles'; import { Observable, of, throwError as observableThrowError, + throwError, } from 'rxjs'; import { @@ -66,10 +72,10 @@ import { SaveForLaterSubmissionFormSuccessAction, SaveSubmissionFormErrorAction, SaveSubmissionFormSuccessAction, - SaveSubmissionSectionFormErrorAction, SaveSubmissionSectionFormSuccessAction, SubmissionObjectActionTypes, UpdateSectionDataAction, + UpdateSectionErrorsAction, } from './submission-objects.actions'; import { SubmissionObjectEffects } from './submission-objects.effects'; @@ -125,8 +131,8 @@ describe('SubmissionObjectEffects test suite', () => { { provide: SubmissionJsonPatchOperationsService, useValue: submissionJsonPatchOperationsServiceStub }, { provide: WorkspaceitemDataService, useValue: {} }, { provide: WorkflowItemDataService, useValue: {} }, - { provide: WorkflowItemDataService, useValue: {} }, - { provide: HALEndpointService, useValue: {} }, + { provide: EditItemDataService, useValue: {} }, + { provide: HALEndpointService, useValue: new HALEndpointServiceStub('test') }, { provide: SubmissionObjectService, useValue: submissionObjectDataServiceStub }, { provide: WorkspaceitemDataService, useValue: workspaceItemDataService }, ], @@ -304,16 +310,39 @@ describe('SubmissionObjectEffects test suite', () => { }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( - () => observableThrowError('Error'), + () => throwError(() => createFailedRemoteDataObject(undefined, undefined, undefined)), ); const expected = cold('--b-', { b: new SaveSubmissionFormErrorAction( - submissionId, + submissionId, undefined, undefined, ), }); expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); }); + + it('should return a UPDATE_SECTION_ERRORS actions on pathable errors', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM, + payload: { + submissionId: submissionId, + }, + }, + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( + () => throwError(() => createFailedRemoteDataObject('error', 422, undefined, mockSectionsErrors)), + ); + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--(ab)-', { + a: new UpdateSectionErrorsAction(submissionId, 'traditionalpageone', errorsList.traditionalpageone, errorsList.traditionalpageone), + b: new UpdateSectionErrorsAction(submissionId, 'license', errorsList.license, errorsList.license), + }); + + expect(submissionObjectEffects.saveSubmission$).toBeObservable(expected); + }); + }); describe('saveForLaterSubmission$', () => { @@ -349,16 +378,38 @@ describe('SubmissionObjectEffects test suite', () => { }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( - () => observableThrowError('Error'), + () => throwError(() => createFailedRemoteDataObject(undefined, undefined, undefined)), ); const expected = cold('--b-', { b: new SaveSubmissionFormErrorAction( - submissionId, + submissionId, undefined, undefined, ), }); expect(submissionObjectEffects.saveForLaterSubmission$).toBeObservable(expected); }); + + it('should return a UPDATE_SECTION_ERRORS actions on pathable errors', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM, + payload: { + submissionId: submissionId, + }, + }, + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( + () => throwError(() => createFailedRemoteDataObject('error', 422, undefined, mockSectionsErrors)), + ); + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--(ab)-', { + a: new UpdateSectionErrorsAction(submissionId, 'traditionalpageone', errorsList.traditionalpageone, errorsList.traditionalpageone), + b: new UpdateSectionErrorsAction(submissionId, 'license', errorsList.license, errorsList.license), + }); + + expect(submissionObjectEffects.saveForLaterSubmission$).toBeObservable(expected); + }); }); describe('saveSubmissionSuccess$', () => { @@ -863,7 +914,7 @@ describe('SubmissionObjectEffects test suite', () => { expect(submissionObjectEffects.saveSection$).toBeObservable(expected); }); - it('should return a SAVE_SUBMISSION_SECTION_FORM_ERROR action on error', () => { + it('should return a SAVE_SUBMISSION_FORM_ERROR action on error', () => { actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM, @@ -875,16 +926,39 @@ describe('SubmissionObjectEffects test suite', () => { }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceID.and.callFake( - () => observableThrowError('Error'), + () => throwError(() => createFailedRemoteDataObject(undefined, undefined, undefined)), ); const expected = cold('--b-', { - b: new SaveSubmissionSectionFormErrorAction( - submissionId, + b: new SaveSubmissionFormErrorAction( + submissionId, undefined, undefined, ), }); expect(submissionObjectEffects.saveSection$).toBeObservable(expected); }); + + it('should return a UPDATE_SECTION_ERRORS actions on pathable errors', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM, + payload: { + submissionId: submissionId, + sectionId: 'traditionalpageone', + }, + }, + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceID.and.callFake( + () => throwError(() => createFailedRemoteDataObject('error', 422, undefined, mockSectionsErrors)), + ); + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--(ab)-', { + a: new UpdateSectionErrorsAction(submissionId, 'traditionalpageone', errorsList.traditionalpageone, errorsList.traditionalpageone), + b: new UpdateSectionErrorsAction(submissionId, 'license', errorsList.license, errorsList.license), + }); + + expect(submissionObjectEffects.saveSection$).toBeObservable(expected); + }); }); describe('saveAndDepositSection$', () => { @@ -955,16 +1029,38 @@ describe('SubmissionObjectEffects test suite', () => { }); submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( - () => observableThrowError('Error'), + () => throwError(() => createFailedRemoteDataObject(undefined, undefined, undefined)), ); const expected = cold('--b-', { b: new SaveSubmissionFormErrorAction( - submissionId, + submissionId, undefined, undefined, ), }); expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); }); + + it('should return a UPDATE_SECTION_ERRORS actions on pathable errors', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION, + payload: { + submissionId: submissionId, + }, + }, + }); + + submissionJsonPatchOperationsServiceStub.jsonPatchByResourceType.and.callFake( + () => observableThrowError(createFailedRemoteDataObject('error', 422, undefined, mockSectionsErrors)), + ); + const errorsList = parseSectionErrors(mockSectionsErrors); + const expected = cold('--(ab)-', { + a: new UpdateSectionErrorsAction(submissionId, 'traditionalpageone', errorsList.traditionalpageone, errorsList.traditionalpageone), + b: new UpdateSectionErrorsAction(submissionId, 'license', errorsList.license, errorsList.license), + }); + + expect(submissionObjectEffects.saveAndDeposit$).toBeObservable(expected); + }); }); describe('depositSubmission$', () => { @@ -1011,7 +1107,7 @@ describe('SubmissionObjectEffects test suite', () => { }); submissionServiceStub.depositSubmission.and.callFake( - () => observableThrowError('Error'), + () => throwError(() => createFailedRemoteDataObject(undefined, undefined, undefined)), ); const expected = cold('--b-', { b: new DepositSubmissionErrorAction( @@ -1025,6 +1121,7 @@ describe('SubmissionObjectEffects test suite', () => { describe('saveForLaterSubmissionSuccess$', () => { it('should display a new success notification and redirect to mydspace', () => { + submissionServiceStub.getSubmissionScope.and.returnValue(SubmissionScopeType.WorkspaceItem); actions = hot('--a-', { a: { type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS, @@ -1035,10 +1132,30 @@ describe('SubmissionObjectEffects test suite', () => { }, }); - submissionObjectEffects.saveForLaterSubmissionSuccess$.subscribe(() => { - expect(notificationsServiceStub.success).toHaveBeenCalled(); - expect(submissionServiceStub.redirectToMyDSpace).toHaveBeenCalled(); + submissionObjectEffects.saveForLaterSubmissionSuccess$.subscribe(); + + getTestScheduler().flush(); + expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(submissionServiceStub.redirectToMyDSpace).toHaveBeenCalled(); + }); + + it('should redirect to item page when the submission scope is EditItem', () => { + + submissionServiceStub.getSubmissionScope.and.returnValue(SubmissionScopeType.EditItem); + + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS, + payload: { + submissionId: submissionId, + submissionObject: mockSubmissionRestResponse, + }, + }, }); + submissionObjectEffects.saveForLaterSubmissionSuccess$.subscribe(); + + getTestScheduler().flush(); + expect(submissionServiceStub.redirectToItemPage).toHaveBeenCalled(); }); }); @@ -1153,7 +1270,7 @@ describe('SubmissionObjectEffects test suite', () => { }); submissionServiceStub.discardSubmission.and.callFake( - () => observableThrowError('Error'), + () => throwError(() => 'Error'), ); const expected = cold('--b-', { b: new DiscardSubmissionErrorAction( diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index 3edc5ffaa13..ed53c03232b 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -1,10 +1,14 @@ import { Injectable } from '@angular/core'; import { RemoteData } from '@dspace/core/data/remote-data'; +import { NotificationOptions } from '@dspace/core/notification-system/models/notification-options.model'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { followLink } from '@dspace/core/shared/follow-link-config.model'; import { Item } from '@dspace/core/shared/item.model'; import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; -import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; +import { + SubmissionObject, + SubmissionObjectError, +} from '@dspace/core/submission/models/submission-object.model'; import { SubmissionSectionError } from '@dspace/core/submission/models/submission-section-error.model'; import { SubmissionSectionObject } from '@dspace/core/submission/models/submission-section-object.model'; import { WorkflowItem } from '@dspace/core/submission/models/workflowitem.model'; @@ -14,6 +18,7 @@ import { WorkspaceitemSectionUploadObject } from '@dspace/core/submission/models import { WorkspaceitemSectionsObject } from '@dspace/core/submission/models/workspaceitem-sections.model'; import { SectionsType } from '@dspace/core/submission/sections-type'; import { SubmissionJsonPatchOperationsService } from '@dspace/core/submission/submission-json-patch-operations.service'; +import { SubmissionScopeType } from '@dspace/core/submission/submission-scope-type'; import { WorkspaceitemDataService } from '@dspace/core/submission/workspaceitem-data.service'; import { isEmpty, @@ -37,6 +42,7 @@ import { } from 'rxjs'; import { catchError, + concatMap, filter, map, mergeMap, @@ -60,6 +66,9 @@ import { DepositSubmissionAction, DepositSubmissionErrorAction, DepositSubmissionSuccessAction, + DisableSectionAction, + DisableSectionErrorAction, + DisableSectionSuccessAction, DiscardSubmissionErrorAction, DiscardSubmissionSuccessAction, InitSectionAction, @@ -78,6 +87,7 @@ import { SubmissionObjectActionTypes, UpdateSectionDataAction, UpdateSectionDataSuccessAction, + UpdateSectionErrorsAction, } from './submission-objects.actions'; import { SubmissionObjectEntry } from './submission-objects.reducer'; @@ -157,13 +167,20 @@ export class SubmissionObjectEffects { */ saveSubmission$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM), - switchMap((action: SaveSubmissionFormAction) => { + concatMap((action: SaveSubmissionFormAction) => { return this.operationsService.jsonPatchByResourceType( this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, - 'sections').pipe( + 'sections', + ).pipe( map((response: SubmissionObject[]) => new SaveSubmissionFormSuccessAction(action.payload.submissionId, response, action.payload.isManual, action.payload.isManual)), - catchError(() => of(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + catchError((rd: unknown) => { + if (rd instanceof RemoteData) { + return observableFrom( + this.parseErrorResponse(false, rd.errors, action.payload.submissionId, rd.statusCode, rd.errorMessage), + ); + } + })); }))); /** @@ -171,13 +188,20 @@ export class SubmissionObjectEffects { */ saveForLaterSubmission$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM), - switchMap((action: SaveForLaterSubmissionFormAction) => { + concatMap((action: SaveForLaterSubmissionFormAction) => { return this.operationsService.jsonPatchByResourceType( this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, - 'sections').pipe( + 'sections', + ).pipe( map((response: SubmissionObject[]) => new SaveForLaterSubmissionFormSuccessAction(action.payload.submissionId, response)), - catchError(() => of(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + catchError((rd: unknown) => { + if (rd instanceof RemoteData) { + return observableFrom( + this.parseErrorResponse(false, rd.errors, action.payload.submissionId, rd.statusCode, rd.errorMessage), + ); + } + })); }))); /** @@ -211,14 +235,21 @@ export class SubmissionObjectEffects { */ saveSection$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM), - switchMap((action: SaveSubmissionSectionFormAction) => { + concatMap((action: SaveSubmissionSectionFormAction) => { return this.operationsService.jsonPatchByResourceID( this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, 'sections', - action.payload.sectionId).pipe( + action.payload.sectionId, + ).pipe( map((response: SubmissionObject[]) => new SaveSubmissionSectionFormSuccessAction(action.payload.submissionId, response)), - catchError(() => of(new SaveSubmissionSectionFormErrorAction(action.payload.submissionId)))); + catchError((rd: unknown) => { + if (rd instanceof RemoteData) { + return observableFrom( + this.parseErrorResponse(false, rd.errors, action.payload.submissionId, rd.statusCode, rd.errorMessage), + ); + } + })); }))); /** @@ -235,7 +266,7 @@ export class SubmissionObjectEffects { saveAndDeposit$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION), withLatestFrom(this.submissionService.hasUnsavedModification()), - switchMap(([action, hasUnsavedModification]: [SaveAndDepositSubmissionAction, boolean]) => { + concatMap(([action, hasUnsavedModification]: [SaveAndDepositSubmissionAction, boolean]) => { let response$: Observable; if (hasUnsavedModification) { response$ = this.operationsService.jsonPatchByResourceType( @@ -262,9 +293,28 @@ export class SubmissionObjectEffects { return new SaveSubmissionFormSuccessAction(action.payload.submissionId, response, false, true); } }), - catchError(() => of(new SaveSubmissionFormErrorAction(action.payload.submissionId)))); + catchError((rd: unknown) => { + if (rd instanceof RemoteData) { + return observableFrom( + this.parseErrorResponse(false, rd.errors, action.payload.submissionId, rd.statusCode, rd.errorMessage), + ); + } + })); }))); + removeSection$ = createEffect(() => this.actions$.pipe( + ofType(SubmissionObjectActionTypes.DISABLE_SECTION), + concatMap((action: DisableSectionAction) => { + return this.operationsService.jsonPatchByResourceID( + this.submissionService.getSubmissionObjectLinkName(), + action.payload.submissionId, + 'sections', + action.payload.sectionId).pipe( + map(() => new DisableSectionSuccessAction(action.payload.submissionId, action.payload.sectionId)), + catchError(() => of(new DisableSectionErrorAction(action.payload.submissionId, action.payload.sectionId)))); + })), + ); + /** * Dispatch a [DepositSubmissionSuccessAction] or a [DepositSubmissionErrorAction] on error */ @@ -283,7 +333,14 @@ export class SubmissionObjectEffects { saveForLaterSubmissionSuccess$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_SUCCESS), tap(() => this.notificationsService.success(null, this.translate.get('submission.sections.general.save_success_notice'))), - tap(() => this.submissionService.redirectToMyDSpace())), { dispatch: false }); + tap((action: SaveForLaterSubmissionFormSuccessAction) => { + const scope = this.submissionService.getSubmissionScope(); + if (scope === SubmissionScopeType.EditItem) { + this.submissionService.redirectToItemPage(action.payload.submissionId); + } else { + this.submissionService.redirectToMyDSpace(); + } + })), { dispatch: false }); /** * Show a notification on success and redirect to MyDSpace page @@ -484,6 +541,62 @@ export class SubmissionObjectEffects { } return mappedActions; } + + /** + * Parse the error response retrieved from REST and return actions to dispatch + * + * @param isSaveForSection + * A boolean representing if save has been dispatched for a section or for the entire submission + * @param errors + * The list of submission object error + * @param submissionId + * The submission id + * @param statusCode + * the submission's response error code + * @param errorMessage + * the submission's response error message + * @return SubmissionObjectAction[] + * List of SubmissionObjectAction to dispatch + */ + protected parseErrorResponse( + isSaveForSection: boolean, + errors: SubmissionObjectError[], + submissionId: string, + statusCode: number, + errorMessage: string, + ): SubmissionObjectAction[] { + + const mappedActions = []; + let errorsList = Object.create({}); + + if (errors && isNotEmpty(errors)) { + // to avoid dispatching an action for every error, create an array of errors per section + errorsList = parseSectionErrors(errors); + } + + if (isNotEmpty(errorsList)) { + // Notify warning message + this.notificationsService.warning( + null, + this.translate.get('submission.sections.general.invalid_state_error'), + new NotificationOptions(10000), + ); + + // Dispatch actions to update section errors + Object.keys(errorsList).forEach((sectionId) => { + const sectionErrors = errorsList[sectionId] || []; + mappedActions.push(new UpdateSectionErrorsAction(submissionId, sectionId, sectionErrors, sectionErrors)); + }); + } else { + if (isSaveForSection) { + mappedActions.push(new SaveSubmissionSectionFormErrorAction(submissionId, statusCode, errorMessage)); + } else { + mappedActions.push(new SaveSubmissionFormErrorAction(submissionId, statusCode, errorMessage)); + } + } + + return mappedActions; + } } function getForm(forms, currentState, sectionId) { diff --git a/src/app/submission/objects/submission-objects.reducer.spec.ts b/src/app/submission/objects/submission-objects.reducer.spec.ts index 1156b1ca8f8..1f1540a831c 100644 --- a/src/app/submission/objects/submission-objects.reducer.spec.ts +++ b/src/app/submission/objects/submission-objects.reducer.spec.ts @@ -19,6 +19,7 @@ import { DepositSubmissionErrorAction, DepositSubmissionSuccessAction, DisableSectionAction, + DisableSectionSuccessAction, DiscardSubmissionAction, DiscardSubmissionSuccessAction, EditFileDataAction, @@ -41,6 +42,7 @@ import { SectionStatusChangeAction, SubmissionObjectAction, UpdateSectionDataAction, + UpdateSectionErrorsAction, } from './submission-objects.actions'; import { submissionObjectReducer, @@ -69,7 +71,9 @@ describe('submissionReducer test suite', () => { activeSection: null, sections: Object.create(null), isLoading: true, + isDiscarding: false, savePending: false, + saveDecisionPending: false, depositPending: false, }, }; @@ -102,6 +106,7 @@ describe('submissionReducer test suite', () => { activeSection: null, sections: Object.create(null), isLoading: true, + isDiscarding: false, savePending: false, depositPending: false, }, @@ -109,7 +114,7 @@ describe('submissionReducer test suite', () => { const action = new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, {}, submissionDefinition, new Item()); const newState = submissionObjectReducer(initState, action); - + console.log('NEW STATE ', newState); expect(newState).toEqual(expectedState); }); @@ -161,7 +166,7 @@ describe('submissionReducer test suite', () => { expect(newState[826].savePending).toBeFalsy(); - action = new SaveSubmissionFormErrorAction(submissionId); + action = new SaveSubmissionFormErrorAction(submissionId, undefined, undefined); newState = submissionObjectReducer(state, action); expect(newState[826].savePending).toBeFalsy(); @@ -171,7 +176,7 @@ describe('submissionReducer test suite', () => { expect(newState[826].savePending).toBeFalsy(); - action = new SaveSubmissionSectionFormErrorAction(submissionId); + action = new SaveSubmissionSectionFormErrorAction(submissionId, undefined, undefined); newState = submissionObjectReducer(state, action); expect(newState[826].savePending).toBeFalsy(); @@ -222,8 +227,7 @@ describe('submissionReducer test suite', () => { it('should reset state once the discard action is completed successfully', () => { const action: any = new DiscardSubmissionSuccessAction(submissionId); const newState = submissionObjectReducer(initState, action); - - expect(newState).toEqual({}); + expect(newState).toEqual(Object.assign({}, initState, { 826: Object.assign({}, initState[826], { isDiscarding: true }) })); }); it('should return same state once the discard action is completed unsuccessfully', () => { @@ -248,6 +252,7 @@ describe('submissionReducer test suite', () => { serverValidationErrors: [], isLoading: false, isValid: true, + removePending: false, } as any; let action: any = new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, {}, new Item(), null); @@ -288,6 +293,13 @@ describe('submissionReducer test suite', () => { action = new DisableSectionAction(submissionId, 'traditionalpagetwo'); newState = submissionObjectReducer(newState, action); + expect(newState[826].sections.traditionalpagetwo.removePending).toBeTruthy(); + expect(newState[826].sections.traditionalpagetwo.enabled).toBeTruthy(); + + action = new DisableSectionSuccessAction(submissionId, 'traditionalpagetwo'); + newState = submissionObjectReducer(newState, action); + + expect(newState[826].sections.traditionalpagetwo.removePending).toBeFalsy(); expect(newState[826].sections.traditionalpagetwo.enabled).toBeFalsy(); }); @@ -369,6 +381,21 @@ describe('submissionReducer test suite', () => { expect(newState[826].sections.traditionalpageone.errorsToShow).toEqual(errors); }); + it('should add submission section errors properly', () => { + const errors = [ + { + path: '/sections/traditionalpageone/dc.title/0', + message: 'error.validation.traditionalpageone.required', + }, + ]; + + const action = new UpdateSectionErrorsAction(submissionId, 'traditionalpageone', errors, errors); + const newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.traditionalpageone.errorsToShow).toEqual(errors); + expect(newState[826].savePending).toBeFalsy(); + }); + it('should remove all submission section errors properly', () => { const action: any = new RemoveSectionErrorsAction(submissionId, 'traditionalpageone'); let newState; diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts index f1ab23e39c1..a82c2e4f3a8 100644 --- a/src/app/submission/objects/submission-objects.reducer.ts +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -23,6 +23,8 @@ import { DepositSubmissionErrorAction, DepositSubmissionSuccessAction, DisableSectionAction, + DisableSectionErrorAction, + DiscardSubmissionSuccessAction, EditFileDataAction, EditFilePrimaryBitstreamAction, EnableSectionAction, @@ -48,6 +50,7 @@ import { SubmissionObjectAction, SubmissionObjectActionTypes, UpdateSectionDataAction, + UpdateSectionErrorsAction, } from './submission-objects.actions'; /** @@ -96,10 +99,19 @@ export interface SubmissionObjectEntry { */ savePending?: boolean; + /** + * A boolean representing if a duplicate decision is pending + */ + saveDecisionPending?: boolean; + /** * A boolean representing if a submission deposit operation is pending */ depositPending?: boolean; + /** + * A boolean representing if a submission is discarded or not + */ + isDiscarding?: boolean; } /** @@ -170,7 +182,7 @@ export function submissionObjectReducer(state = initialState, action: Submission } case SubmissionObjectActionTypes.DISCARD_SUBMISSION_SUCCESS: { - return initialState; + return discardSuccess(state, action as DiscardSubmissionSuccessAction); } case SubmissionObjectActionTypes.DISCARD_SUBMISSION_ERROR: { @@ -199,10 +211,22 @@ export function submissionObjectReducer(state = initialState, action: Submission return updateSectionData(state, action as UpdateSectionDataAction); } + case SubmissionObjectActionTypes.UPDATE_SECTION_ERRORS: { + return updateSectionErrors(state, action as UpdateSectionErrorsAction); + } + case SubmissionObjectActionTypes.DISABLE_SECTION: { + return changeSectionRemoveState(state, action as DisableSectionAction, true); + } + + case SubmissionObjectActionTypes.DISABLE_SECTION_SUCCESS: { return changeSectionState(state, action as DisableSectionAction, false); } + case SubmissionObjectActionTypes.DISABLE_SECTION_ERROR: { + return changeSectionRemoveState(state, action as DisableSectionErrorAction, false); + } + case SubmissionObjectActionTypes.SECTION_STATUS_CHANGE: { return setIsValid(state, action as SectionStatusChangeAction); } @@ -346,7 +370,9 @@ function initSubmission(state: SubmissionObjectState, action: InitSubmissionForm sections: Object.create(null), isLoading: true, savePending: false, + saveDecisionPending: false, depositPending: false, + isDiscarding: false, }; return newState; } @@ -396,6 +422,28 @@ function completeInit(state: SubmissionObjectState, action: CompleteInitSubmissi } } +/** + * Set submission discard to true. + * + * @param state + * the current state + * @param action + * a DiscardSubmissionSuccessAction + * @return SubmissionObjectState + * the new state, with the discard success. + */ +function discardSuccess(state: SubmissionObjectState, action: DiscardSubmissionSuccessAction): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + isDiscarding: true, + }), + }); + } else { + return state; + } +} + /** * Set submission save flag to true * @@ -575,6 +623,7 @@ function initSection(state: SubmissionObjectState, action: InitSectionAction): S serverValidationErrors: action.payload.errors || [], isLoading: false, isValid: isEmpty(action.payload.errors), + removePending: false, }, }), }), @@ -642,6 +691,36 @@ function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDa } } +/** + * Update section's data. + * + * @param state + * the current state + * @param action + * an UpdateSectionDataAction + * @return SubmissionObjectState + * the new state, with the section's data updated. + */ +function updateSectionErrors(state: SubmissionObjectState, action: UpdateSectionErrorsAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) + && isNotEmpty(state[ action.payload.submissionId ].sections[ action.payload.sectionId])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + enabled: true, + errorsToShow: action.payload.errorsToShow, + serverValidationErrors: action.payload.errorsToShow, + }), + }), + savePending: false, + }), + }); + } else { + return state; + } +} + /** * Updates the state of the section metadata only when a new value is provided. * Keep the existent otherwise. @@ -680,6 +759,37 @@ function changeSectionState(state: SubmissionObjectState, action: EnableSectionA sections: Object.assign({}, state[ action.payload.submissionId ].sections, { [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { enabled, + data: (enabled) ? state[ action.payload.submissionId ].sections [ action.payload.sectionId ] : {}, + removePending: false, + }), + }), + }), + }); + } else { + return state; + } +} + +/** + * Change removePending flag. + * + * @param state + * the current state + * @param action + * a DisableSectionAction or a DisableSectionErrorAction + * @param removePending + * representing if remove operation is pending or not. + * @return SubmissionObjectState + * the new state, with the section removed. + */ +function changeSectionRemoveState(state: SubmissionObjectState, action: DisableSectionAction | DisableSectionErrorAction, removePending: boolean): SubmissionObjectState { + if (hasValue(state[ action.payload.submissionId ].sections[ action.payload.sectionId ])) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + // sections: deleteProperty(state[ action.payload.submissionId ].sections, action.payload.sectionId), + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ action.payload.sectionId ]: Object.assign({}, state[ action.payload.submissionId ].sections [ action.payload.sectionId ], { + removePending, }), }), }), @@ -845,12 +955,20 @@ function editFileData(state: SubmissionObjectState, action: EditFileDataAction): */ function deleteFile(state: SubmissionObjectState, action: DeleteUploadedFileAction): SubmissionObjectState { const filesData = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data as WorkspaceitemSectionUploadObject; + const filesErrorsToShow = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].errorsToShow ?? []; + const filesSeverValidationErrors = state[ action.payload.submissionId ].sections[ action.payload.sectionId ].serverValidationErrors ?? []; + if (hasValue(filesData.files)) { const fileIndex: any = findKey( filesData.files, { uuid: action.payload.fileId }); if (isNotNull(fileIndex)) { const newData = Array.from(filesData.files); + const newErrorsToShow = filesData.files.length > 1 ? filesErrorsToShow + .filter(errorToShow => !errorToShow.path.includes(fileIndex)) : []; + const newServerErrorsToShow = filesData.files.length > 1 ? filesSeverValidationErrors + .filter(serverError => !serverError.path.includes(fileIndex)) : []; + newData.splice(fileIndex, 1); return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[action.payload.submissionId], { @@ -860,6 +978,8 @@ function deleteFile(state: SubmissionObjectState, action: DeleteUploadedFileActi data: Object.assign({}, state[ action.payload.submissionId ].sections[ action.payload.sectionId ].data, { files: newData, }), + errorsToShow: newErrorsToShow, + serverValidationErrors: newServerErrorsToShow, }), }), ), diff --git a/src/app/submission/submission-object.service.spec.ts b/src/app/submission/submission-object.service.spec.ts index 8f6d570846c..8789f111a20 100644 --- a/src/app/submission/submission-object.service.spec.ts +++ b/src/app/submission/submission-object.service.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { RemoteData } from '@dspace/core/data/remote-data'; import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; +import { EditItemDataService } from '@dspace/core/submission/edititem-data.service'; import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; import { SubmissionScopeType } from '@dspace/core/submission/submission-scope-type'; import { WorkflowItemDataService } from '@dspace/core/submission/workflowitem-data.service'; @@ -15,11 +16,13 @@ describe('SubmissionObjectService', () => { let submissionService: SubmissionService; let workspaceitemDataService: WorkspaceitemDataService; let workflowItemDataService: WorkflowItemDataService; + let editItemDataService: EditItemDataService; let halService: HALEndpointService; const submissionId = '1234'; const wsiResult = 'wsiResult' as any; const wfiResult = 'wfiResult' as any; + const eiResult = 'eiResult' as any; beforeEach(() => { workspaceitemDataService = jasmine.createSpyObj('WorkspaceitemDataService', { @@ -28,6 +31,9 @@ describe('SubmissionObjectService', () => { workflowItemDataService = jasmine.createSpyObj('WorkflowItemDataService', { findById: wfiResult, }); + editItemDataService = jasmine.createSpyObj('EditItemDataService', { + findById: eiResult, + }); halService = jasmine.createSpyObj('HALEndpointService', { getEndpoint: '/workspaceItem', }); @@ -38,6 +44,7 @@ describe('SubmissionObjectService', () => { { provide: WorkflowItemDataService, useValue: workflowItemDataService }, { provide: HALEndpointService, useValue: halService }, { provide: SubmissionService, useValue: submissionService }, + { provide: EditItemDataService, useValue: editItemDataService }, { provide: APP_CONFIG, useValue: { cache : { msToLive: { default : 15 * 60 * 1000 } } } }, SubmissionObjectService, ], diff --git a/src/app/submission/submission-object.service.ts b/src/app/submission/submission-object.service.ts index 8caa5f3f352..0ccb9496bf2 100644 --- a/src/app/submission/submission-object.service.ts +++ b/src/app/submission/submission-object.service.ts @@ -11,6 +11,7 @@ import { RemoteData } from '@dspace/core/data/remote-data'; import { RequestEntryState } from '@dspace/core/data/request-entry-state.model'; import { FollowLinkConfig } from '@dspace/core/shared/follow-link-config.model'; import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; +import { EditItemDataService } from '@dspace/core/submission/edititem-data.service'; import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; import { SubmissionScopeType } from '@dspace/core/submission/submission-scope-type'; import { WorkflowItemDataService } from '@dspace/core/submission/workflowitem-data.service'; @@ -36,6 +37,7 @@ export class SubmissionObjectService { constructor( private workspaceitemDataService: WorkspaceitemDataService, private workflowItemDataService: WorkflowItemDataService, + private editItemDataService: EditItemDataService, private submissionService: SubmissionService, private halService: HALEndpointService, ) { @@ -46,7 +48,18 @@ export class SubmissionObjectService { * @param id The identifier for the object */ getHrefByID(id): Observable { - const dataService: IdentifiableDataService = this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem ? this.workspaceitemDataService : this.workflowItemDataService; + let dataService: IdentifiableDataService = this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem ? this.workspaceitemDataService : this.workflowItemDataService; + switch (this.submissionService.getSubmissionScope()) { + case SubmissionScopeType.WorkspaceItem: + dataService = this.workspaceitemDataService; + break; + case SubmissionScopeType.WorkflowItem: + dataService = this.workflowItemDataService; + break; + case SubmissionScopeType.EditItem: + dataService = this.editItemDataService; + break; + } return this.halService.getEndpoint(dataService.getLinkPath()).pipe( map((endpoint: string) => dataService.getIDHref(endpoint, encodeURIComponent(id)))); @@ -69,6 +82,8 @@ export class SubmissionObjectService { return this.workspaceitemDataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); case SubmissionScopeType.WorkflowItem: return this.workflowItemDataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + case SubmissionScopeType.EditItem: + return this.editItemDataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale,...linksToFollow); default: { const now = new Date().getTime(); return of(new RemoteData( diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts index aa5b9862f59..1e64ca2a069 100644 --- a/src/app/submission/submission.service.spec.ts +++ b/src/app/submission/submission.service.spec.ts @@ -1,4 +1,7 @@ -import { HttpHeaders } from '@angular/common/http'; +import { + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { fakeAsync, flush, @@ -225,6 +228,7 @@ describe('SubmissionService test suite', () => { }, isLoading: false, savePending: false, + saveDecisionPending: false, depositPending: false, }, }, @@ -382,6 +386,7 @@ describe('SubmissionService test suite', () => { }, isLoading: false, savePending: false, + saveDecisionPending: false, depositPending: false, }, }, @@ -442,17 +447,38 @@ describe('SubmissionService test suite', () => { describe('createSubmission', () => { it('should create a new submission', () => { + const paramsObj = Object.create({}); + const params = new HttpParams({ fromObject: paramsObj }); + const options: HttpOptions = Object.create({}); + options.params = params; service.createSubmission(); expect((service as any).restService.postToEndpoint).toHaveBeenCalled(); - expect((service as any).restService.postToEndpoint).toHaveBeenCalledWith('workspaceitems', {}, null, null, undefined); + expect((service as any).restService.postToEndpoint).toHaveBeenCalledWith('workspaceitems', {}, null, options, undefined); + }); + + it('should create a new submission with entity type', () => { + const entityType = 'Publication'; + const params = new HttpParams({ fromObject: { entityType: entityType } }); + const options: HttpOptions = Object.create({}); + options.params = params; + + service.createSubmission(undefined, 'Publication'); + + expect((service as any).restService.postToEndpoint).toHaveBeenCalled(); + expect((service as any).restService.postToEndpoint).toHaveBeenCalledWith('workspaceitems', {}, null, options, undefined); }); it('should create a new submission with collection', () => { + const paramsObj = Object.create({}); + const params = new HttpParams({ fromObject: paramsObj }); + const options: HttpOptions = Object.create({}); + options.params = params; + service.createSubmission(collectionId); expect((service as any).restService.postToEndpoint).toHaveBeenCalled(); - expect((service as any).restService.postToEndpoint).toHaveBeenCalledWith('workspaceitems', {}, null, null, collectionId); + expect((service as any).restService.postToEndpoint).toHaveBeenCalledWith('workspaceitems', {}, null, options, collectionId); }); }); @@ -1086,6 +1112,27 @@ describe('SubmissionService test suite', () => { }); }); + describe('redirectToEditItem', () => { + it('should redirect to Item page', () => { + scheduler = getTestScheduler(); + + const itemUuid = 'd62fc60f-e9a5-48e6-973a-90819acf23ae'; + let itemSubmissionId = itemUuid + ':FULL'; + + scheduler.schedule(() => service.redirectToItemPage(itemSubmissionId)); + scheduler.flush(); + + expect((service as any).router.navigateByUrl).toHaveBeenCalledWith('/items/' + itemUuid, { replaceUrl: true }); + + itemSubmissionId = itemUuid; + scheduler.schedule(() => service.redirectToItemPage(itemSubmissionId)); + scheduler.flush(); + + expect((service as any).router.navigateByUrl).toHaveBeenCalledWith('/items/' + itemUuid, { replaceUrl: true }); + + }); + }); + describe('resetAllSubmissionObjects', () => { it('should dispatch a new CancelSubmissionFormAction', () => { service.resetAllSubmissionObjects(); diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index 428a13766a0..fbbf8547081 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -1,4 +1,7 @@ -import { HttpHeaders } from '@angular/common/http'; +import { + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { ErrorResponse } from '@dspace/core/cache/response.models'; @@ -25,6 +28,7 @@ import { import { hasValue, isEmpty, + isNotEmpty, isNotUndefined, } from '@dspace/shared/utils/empty.util'; import { @@ -109,6 +113,8 @@ export class SubmissionService { private workspaceLinkPath = 'workspaceitems'; private workflowLinkPath = 'workflowitems'; + private editItemsLinkPath = 'edititems'; + /** * Initialize service variables * @param {NotificationsService} notificationsService @@ -158,11 +164,22 @@ export class SubmissionService { * * @param collectionId * The owning collection id + * @param entityType + * The entity type * @return Observable * observable of SubmissionObject */ - createSubmission(collectionId?: string): Observable { - return this.restService.postToEndpoint(this.workspaceLinkPath, {}, null, null, collectionId).pipe( + createSubmission(collectionId?: string, entityType?: string): Observable { + const paramsObj = Object.create({}); + + if (isNotEmpty(entityType)) { + paramsObj.entityType = entityType; + } + + const params = new HttpParams({ fromObject: paramsObj }); + const options: HttpOptions = Object.create({}); + options.params = params; + return this.restService.postToEndpoint(this.workspaceLinkPath, {}, null, options, collectionId).pipe( map((workspaceitem: SubmissionObject[]) => workspaceitem[0] as SubmissionObject), catchError(() => of({} as SubmissionObject))); } @@ -400,7 +417,7 @@ export class SubmissionService { } else if (url.startsWith('/workflowitems')) { return this.workflowLinkPath; } else { - return 'edititems'; + return this.editItemsLinkPath; } } @@ -419,6 +436,9 @@ export class SubmissionService { case this.workflowLinkPath: scope = SubmissionScopeType.WorkflowItem; break; + case this.editItemsLinkPath: + scope = SubmissionScopeType.EditItem; + break; } return scope; } @@ -530,6 +550,21 @@ export class SubmissionService { distinctUntilChanged()); } + /** + * Return the discard status of the submission + * + * @param submissionId + * The submission id + * @return Observable + * observable with submission discard status + */ + isSubmissionDiscarding(submissionId: string): Observable { + return this.store.select(submissionObjectFromIdSelector(submissionId)).pipe( + map((submission: SubmissionObjectEntry) => isEmpty(submission) || submission?.isDiscarding), + distinctUntilChanged(), + ); + } + /** * Show a notification when a new section is added to submission form * @@ -568,6 +603,14 @@ export class SubmissionService { ).subscribe(); } + /** + * Redirect to Item page + */ + redirectToItemPage(submissionId: string) { + const itemUuid = submissionId.indexOf(':') > -1 ? submissionId.split(':')[0] : submissionId; + this.router.navigateByUrl('/items/' + itemUuid, { replaceUrl: true }); + } + /** * Dispatch a new [CancelSubmissionFormAction] */ @@ -606,8 +649,8 @@ export class SubmissionService { * @return Observable> * observable of RemoteData */ - retrieveSubmission(submissionId): Observable> { - return this.restService.getDataById(this.getSubmissionObjectLinkName(), submissionId).pipe( + retrieveSubmission(submissionId, projections: string[] = []): Observable> { + return this.restService.getDataById(this.getSubmissionObjectLinkName(), submissionId, false, projections).pipe( find((submissionObjects: SubmissionObject[]) => isNotUndefined(submissionObjects)), map((submissionObjects: SubmissionObject[]) => createSuccessfulRemoteDataObject( submissionObjects[0])), diff --git a/src/app/submission/utils/submission.mock.ts b/src/app/submission/utils/submission.mock.ts index 991c6afc943..6da5847542d 100644 --- a/src/app/submission/utils/submission.mock.ts +++ b/src/app/submission/utils/submission.mock.ts @@ -1121,6 +1121,7 @@ export const mockSubmissionState: SubmissionObjectState = Object.assign({}, { } as any, }, isLoading: false, + isDiscarding: false, savePending: false, depositPending: false, }, @@ -1147,6 +1148,7 @@ export const mockSubmissionStateWithDuplicate: SubmissionObjectState = Object.as errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, collection: { config: '', @@ -1162,6 +1164,7 @@ export const mockSubmissionStateWithDuplicate: SubmissionObjectState = Object.as errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, traditionalpageone: { header: 'submit.progressbar.describe.stepone', @@ -1175,6 +1178,7 @@ export const mockSubmissionStateWithDuplicate: SubmissionObjectState = Object.as formId: '2_traditionalpageone', isLoading: false, isValid: false, + removePending: false, } as any, traditionalpagetwo: { header: 'submit.progressbar.describe.steptwo', @@ -1187,6 +1191,7 @@ export const mockSubmissionStateWithDuplicate: SubmissionObjectState = Object.as errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, upload: { header: 'submit.progressbar.upload', @@ -1201,6 +1206,7 @@ export const mockSubmissionStateWithDuplicate: SubmissionObjectState = Object.as errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, license: { header: 'submit.progressbar.license', @@ -1217,9 +1223,11 @@ export const mockSubmissionStateWithDuplicate: SubmissionObjectState = Object.as errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, }, isLoading: false, + isDiscarding: false, savePending: false, depositPending: false, }, @@ -1305,6 +1313,7 @@ export const mockSubmissionStateWithoutUpload: SubmissionObjectState = Object.as } as any, }, isLoading: false, + isDiscarding: false, savePending: false, depositPending: false, }, @@ -1340,6 +1349,7 @@ export const mockSectionsState = Object.assign({}, { errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, traditionalpageone: { header: 'submit.progressbar.describe.stepone', @@ -1352,6 +1362,7 @@ export const mockSectionsState = Object.assign({}, { errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, traditionalpagetwo: { header: 'submit.progressbar.describe.steptwo', @@ -1364,6 +1375,7 @@ export const mockSectionsState = Object.assign({}, { errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, upload: { header: 'submit.progressbar.upload', @@ -1376,6 +1388,7 @@ export const mockSectionsState = Object.assign({}, { errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, license: { header: 'submit.progressbar.license', @@ -1392,6 +1405,7 @@ export const mockSectionsState = Object.assign({}, { errorsToShow: [], isLoading: false, isValid: false, + removePending: false, } as any, }); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 563c60ee03d..5bc222385d9 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1665,6 +1665,14 @@ "community.sub-community-list.head": "Communities in this Community", + "confirmation-modal.pending-changes.header": "Unsaved changes", + + "confirmation-modal.pending-changes.info": "There are unsaved changes. Do you want to leave the page?", + + "confirmation-modal.pending-changes.cancel": "Cancel", + + "confirmation-modal.pending-changes.confirm": "Leave", + "context-menu.actions.audit-item.btn": "Audit", "cookies.consent.accept-all": "Accept all", @@ -2967,14 +2975,6 @@ "item.page.edit": "Administer", - "item.page.edit.FULL": "Edit all the details", - - "item.page.edit.OWNER": "Edit", - - "item.page.edit.DIRECTOR": "Edit", - - "item.page.edit.INVESTIGATOR": "Edit", - "item.page.files": "Files", "item.page.filesection.description": "Description:", @@ -3667,6 +3667,14 @@ "menu.section.workflow": "Administer Workflow", + "menu.section.FULL": "Edit all the details", + + "menu.section.OWNER": "Edit", + + "menu.section.DIRECTOR": "Edit", + + "menu.section.INVESTIGATOR": "Edit", + "metadata-export-search.tooltip": "Export search results as CSV", "metadata-export-search.submit.success": "The export was started successfully", From 850a973b3ade387e57051988db9fadd64a45905b Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Tue, 17 Feb 2026 14:38:16 +0100 Subject: [PATCH 03/12] [DURACOM-453] add metadata security and finilize edit mode --- .../collection-form.component.ts | 108 +++++++++-- .../collection-form/collection-form.models.ts | 33 ++++ ...mission-definitions-config-data.service.ts | 36 ++++ src/app/core/data-services-map.ts | 2 + .../models/form-field-metadata-value.model.ts | 19 +- src/app/core/shared/metadata.models.ts | 7 + .../metadatasecurityconfig-data.service.ts | 37 ++++ .../metadata-security-config.resource-type.ts | 4 + .../models/metadata-security-configuration.ts | 54 ++++++ .../submission-response-parsing.service.ts | 5 +- .../models/vocabulary-entry.model.ts | 6 + .../core/testing/submission-service.stub.ts | 1 + ...-edit-metadata-field-values.component.html | 4 +- ...so-edit-metadata-field-values.component.ts | 16 ++ .../dso-edit-metadata-value.component.html | 14 +- .../dso-edit-metadata-value.component.spec.ts | 56 +++++- .../dso-edit-metadata-value.component.ts | 179 +++++++++++++++++- .../dso-edit-metadata.component.html | 5 +- .../dso-edit-metadata.component.spec.ts | 40 +++- .../dso-edit-metadata.component.ts | 146 +++++++++++++- src/app/footer/footer.component.html | 2 +- .../edit-metadata-security.component.html | 21 ++ .../edit-metadata-security.component.scss | 31 +++ .../edit-metadata-security.component.spec.ts | 100 ++++++++++ .../edit-metadata-security.component.ts | 106 +++++++++++ ...amic-form-control-container.component.html | 13 +- ...ynamic-form-control-container.component.ts | 45 +++++ ...dynamic-type-bind-relation.service.spec.ts | 4 +- .../models/ds-dynamic-input.model.ts | 20 ++ .../models/dynamic-vocabulary.component.ts | 16 +- .../lookup/dynamic-lookup.component.spec.ts | 10 +- .../onebox/dynamic-onebox.component.spec.ts | 12 +- .../dynamic-relation-group.component.spec.ts | 28 ++- .../dynamic-relation-group.components.ts | 5 + ...dynamic-relation-group-modal.components.ts | 53 +++++- .../form/builder/form-builder.service.ts | 100 +++++++++- .../builder/parsers/concat-field-parser.ts | 10 +- .../builder/parsers/date-field-parser.spec.ts | 6 +- .../parsers/disabled-field-parser.spec.ts | 6 +- .../parsers/dropdown-field-parser.spec.ts | 6 +- .../builder/parsers/dropdown-field-parser.ts | 4 +- .../form/builder/parsers/field-parser.ts | 50 ++++- .../builder/parsers/list-field-parser.spec.ts | 10 +- .../parsers/lookup-field-parser.spec.ts | 6 +- .../parsers/lookup-name-field-parser.spec.ts | 6 +- .../builder/parsers/name-field-parser.spec.ts | 10 +- .../form/builder/parsers/name-field-parser.ts | 4 +- .../parsers/onebox-field-parser.spec.ts | 10 +- .../form/builder/parsers/parser-factory.ts | 2 + .../relation-group-field-parser.spec.ts | 10 +- .../form/builder/parsers/row-parser.spec.ts | 5 +- .../shared/form/builder/parsers/row-parser.ts | 5 +- .../parsers/series-field-parser.spec.ts | 10 +- .../builder/parsers/series-field-parser.ts | 4 +- .../builder/parsers/tag-field-parser.spec.ts | 6 +- .../parsers/textarea-field-parser.spec.ts | 6 +- .../shared/form/chips/chips.component.spec.ts | 4 +- .../edit/submission-edit.component.html | 1 + .../edit/submission-edit.component.spec.ts | 45 ++++- .../edit/submission-edit.component.ts | 86 +++++++-- .../form/submission-form.component.spec.ts | 17 +- .../form/submission-form.component.ts | 54 ++++-- .../submission-upload-files.component.spec.ts | 14 +- .../form/themed-submission-form.component.ts | 5 +- .../objects/submission-objects.actions.ts | 38 +++- .../form/section-form.component.spec.ts | 2 + .../sections/form/section-form.component.ts | 34 +++- src/app/submission/selectors.ts | 5 + src/app/submission/submission.service.ts | 75 +++++++- src/app/submission/utils/submission.mock.ts | 16 +- src/assets/i18n/en.json5 | 4 + src/config/app-config.interface.ts | 2 + src/config/default-app-config.ts | 22 +++ src/config/metadata-security-config.ts | 11 ++ src/environments/environment.test.ts | 19 ++ 75 files changed, 1762 insertions(+), 206 deletions(-) create mode 100644 src/app/core/config/submission-definitions-config-data.service.ts create mode 100644 src/app/core/submission/metadatasecurityconfig-data.service.ts create mode 100644 src/app/core/submission/models/metadata-security-config.resource-type.ts create mode 100644 src/app/core/submission/models/metadata-security-configuration.ts create mode 100644 src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.html create mode 100644 src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.scss create mode 100644 src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.spec.ts create mode 100644 src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.ts create mode 100644 src/config/metadata-security-config.ts diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index bc049aa6b5b..6bd7e650440 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -10,6 +10,9 @@ import { } from '@angular/core'; import { AuthService } from '@dspace/core/auth/auth.service'; import { ObjectCacheService } from '@dspace/core/cache/object-cache.service'; +import { ConfigObject } from '@dspace/core/config/models/config.model'; +import { SubmissionDefinitionModel } from '@dspace/core/config/models/config-submission-definition.model'; +import { SubmissionDefinitionsConfigDataService } from '@dspace/core/config/submission-definitions-config-data.service'; import { CollectionDataService } from '@dspace/core/data/collection-data.service'; import { EntityTypeDataService } from '@dspace/core/data/entity-type-data.service'; import { RequestService } from '@dspace/core/data/request.service'; @@ -25,6 +28,7 @@ import { } from '@dspace/shared/utils/empty.util'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { + DynamicCheckboxModel, DynamicFormControlModel, DynamicFormOptionConfig, DynamicFormService, @@ -34,7 +38,12 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; +import { + catchError, + combineLatest, + Observable, + of, +} from 'rxjs'; import { ComColFormComponent } from '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component'; import { ComcolPageLogoComponent } from '../../shared/comcol/comcol-page-logo/comcol-page-logo.component'; @@ -42,8 +51,11 @@ import { FormComponent } from '../../shared/form/form.component'; import { UploaderComponent } from '../../shared/upload/uploader/uploader.component'; import { VarDirective } from '../../shared/utils/var.directive'; import { + collectionFormCorrectionSubmissionDefinitionSelectionConfig, collectionFormEntityTypeSelectionConfig, collectionFormModels, + collectionFormSharedWorkspaceCheckboxConfig, + collectionFormSubmissionDefinitionSelectionConfig, } from './collection-form.models'; /** @@ -79,6 +91,20 @@ export class CollectionFormComponent extends ComColFormComponent imp */ entityTypeSelection: DynamicSelectModel = new DynamicSelectModel(collectionFormEntityTypeSelectionConfig); + /** + * The dynamic form field used for submission definition selection + * @type {DynamicSelectModel} + */ + submissionDefinitionSelection: DynamicSelectModel = new DynamicSelectModel(collectionFormSubmissionDefinitionSelectionConfig); + + /** + * The dynamic form field used for correction submission definition selection + * @type {DynamicSelectModel} + */ + correctionSubmissionDefinitionSelection: DynamicSelectModel = new DynamicSelectModel(collectionFormCorrectionSubmissionDefinitionSelectionConfig); + + sharedWorkspaceChekbox: DynamicCheckboxModel = new DynamicCheckboxModel(collectionFormSharedWorkspaceCheckboxConfig); + /** * The dynamic form fields used for creating/editing a collection * @type {DynamicFormControlModel[]} @@ -94,6 +120,7 @@ export class CollectionFormComponent extends ComColFormComponent imp protected objectCache: ObjectCacheService, protected entityTypeService: EntityTypeDataService, protected chd: ChangeDetectorRef, + protected submissionDefinitionService: SubmissionDefinitionsConfigDataService, protected modalService: NgbModal) { super(formService, translate, notificationsService, authService, requestService, objectCache, modalService); } @@ -117,35 +144,76 @@ export class CollectionFormComponent extends ComColFormComponent imp initializeForm() { let currentRelationshipValue: MetadataValue[]; + let currentDefinitionValue: MetadataValue[]; + let currentCorrectionDefinitionValue: MetadataValue[]; + let currentSharedWorkspaceValue: MetadataValue[]; if (this.dso && this.dso.metadata) { currentRelationshipValue = this.dso.metadata['dspace.entity.type']; + currentDefinitionValue = this.dso.metadata['cris.submission.definition']; + currentCorrectionDefinitionValue = this.dso.metadata['cris.submission.definition-correction']; + currentSharedWorkspaceValue = this.dso.metadata['cris.workspace.shared']; } const entities$: Observable = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe( getFirstSucceededRemoteListPayload(), ); - // retrieve all entity types to populate the dropdowns selection - entities$.subscribe((entityTypes: ItemType[]) => { - - entityTypes = entityTypes.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE); - entityTypes.forEach((type: ItemType, index: number) => { - this.entityTypeSelection.add({ - disabled: false, - label: type.label, - value: type.label, - } as DynamicFormOptionConfig); - if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) { - this.entityTypeSelection.select(index); - this.entityTypeSelection.disabled = true; - } - }); + const definitions$: Observable = this.submissionDefinitionService + .findAll({ elementsPerPage: 100, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload(), + catchError(() => of([])), + ); + + // retrieve all entity types and submission definitions to populate the dropdowns selection + combineLatest([entities$, definitions$]) + .subscribe(([entityTypes, definitions]: [ItemType[], SubmissionDefinitionModel[]]) => { + + const sortedEntityTypes = entityTypes + .filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE) + .sort((a, b) => a.label.localeCompare(b.label)); + + sortedEntityTypes.forEach((type: ItemType, index: number) => { + this.entityTypeSelection.add({ + disabled: false, + label: type.label, + value: type.label, + } as DynamicFormOptionConfig); + if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) { + this.entityTypeSelection.select(index); + this.entityTypeSelection.disabled = true; + } + }); - this.formModel = entityTypes.length === 0 ? collectionFormModels : [...collectionFormModels, this.entityTypeSelection]; + definitions.forEach((definition: SubmissionDefinitionModel, index: number) => { + this.submissionDefinitionSelection.add({ + disabled: false, + label: definition.name, + value: definition.name, + } as DynamicFormOptionConfig); + this.correctionSubmissionDefinitionSelection.add({ + disabled: false, + label: definition.name, + value: definition.name, + } as DynamicFormOptionConfig); + if (currentDefinitionValue && currentDefinitionValue.length > 0 && currentDefinitionValue[0].value === definition.name) { + this.submissionDefinitionSelection.select(index); + } + if (currentCorrectionDefinitionValue && currentCorrectionDefinitionValue.length > 0 && currentCorrectionDefinitionValue[0].value === definition.name) { + this.correctionSubmissionDefinitionSelection.select(index); + } + }); - super.ngOnInit(); - this.chd.detectChanges(); - }); + this.formModel = entityTypes.length === 0 ? + [...collectionFormModels, this.submissionDefinitionSelection, this.correctionSubmissionDefinitionSelection, this.sharedWorkspaceChekbox] : + [...collectionFormModels, this.entityTypeSelection, this.submissionDefinitionSelection, this.correctionSubmissionDefinitionSelection, this.sharedWorkspaceChekbox]; + + super.ngOnInit(); + + if (currentSharedWorkspaceValue && currentSharedWorkspaceValue.length > 0) { + this.sharedWorkspaceChekbox.value = currentSharedWorkspaceValue[0].value === 'true'; + } + this.chd.detectChanges(); + }); } } diff --git a/src/app/collection-page/collection-form/collection-form.models.ts b/src/app/collection-page/collection-form/collection-form.models.ts index 90204c246f6..b7eaf36953c 100644 --- a/src/app/collection-page/collection-form/collection-form.models.ts +++ b/src/app/collection-page/collection-form/collection-form.models.ts @@ -1,4 +1,5 @@ import { + DynamicCheckboxModelConfig, DynamicFormControlModel, DynamicInputModel, DynamicSelectModelConfig, @@ -10,6 +11,38 @@ import { environment } from '../../../environments/environment'; export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig = { id: 'entityType', name: 'dspace.entity.type', + required: true, + disabled: false, + validators: { + required: null, + }, + errorMessages: { + required: 'collection.form.errors.entityType.required', + }, +}; + +export const collectionFormSubmissionDefinitionSelectionConfig: DynamicSelectModelConfig = { + id: 'submissionDefinition', + name: 'cris.submission.definition', + required: true, + disabled: false, + validators: { + required: null, + }, + errorMessages: { + required: 'collection.form.errors.submissionDefinition.required', + }, +}; +export const collectionFormCorrectionSubmissionDefinitionSelectionConfig: DynamicSelectModelConfig = { + id: 'correctionSubmissionDefinition', + name: 'cris.submission.definition-correction', + required: false, + disabled: false, +}; + +export const collectionFormSharedWorkspaceCheckboxConfig: DynamicCheckboxModelConfig = { + id: 'sharedWorkspace', + name: 'cris.workspace.shared', disabled: false, }; diff --git a/src/app/core/config/submission-definitions-config-data.service.ts b/src/app/core/config/submission-definitions-config-data.service.ts new file mode 100644 index 00000000000..1748f57cac7 --- /dev/null +++ b/src/app/core/config/submission-definitions-config-data.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { FollowLinkConfig } from '@dspace/core/shared/follow-link-config.model'; +import { Observable } from 'rxjs'; +import { + mergeMap, + take, +} from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; +import { ConfigObject } from './models/config.model'; + +@Injectable({ providedIn: 'root' }) +export class SubmissionDefinitionsConfigDataService extends ConfigDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('submissiondefinitions', requestService, rdbService, objectCache, halService); + } + + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.getBrowseEndpoint(options).pipe( + take(1), + mergeMap((href: string) => super.findListByHref(href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } +} diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts index 216a5dfb7dd..ea7d3b51996 100644 --- a/src/app/core/data-services-map.ts +++ b/src/app/core/data-services-map.ts @@ -59,6 +59,7 @@ import { VERSION_HISTORY } from './shared/version-history.resource-type'; import { USAGE_REPORT } from './statistics/models/usage-report.resource-type'; import { CorrectionType } from './submission/models/correctiontype.model'; import { EditItem } from './submission/models/edititem.model'; +import { METADATA_SECURITY_TYPE } from './submission/models/metadata-security-config.resource-type'; import { SUBMISSION_CC_LICENSE } from './submission/models/submission-cc-licence.resource-type'; import { SUBMISSION_CC_LICENSE_URL } from './submission/models/submission-cc-licence-link.resource-type'; import { @@ -140,4 +141,5 @@ export const LAZY_DATA_SERVICES: LazyDataServicesMap = new Map([ [CorrectionType.type.value, () => import('./submission/correctiontype-data.service').then(m => m.CorrectionTypeDataService)], [AUDIT.value, () => import('./data/audit-data.service').then(m => m.AuditDataService)], [EditItem.type.value, () => import('./submission/edititem-data.service').then(m => m.EditItemDataService)], + [METADATA_SECURITY_TYPE.value, () => import('./submission/metadatasecurityconfig-data.service').then(m => m.MetadataSecurityConfigurationService)], ]); diff --git a/src/app/core/shared/form/models/form-field-metadata-value.model.ts b/src/app/core/shared/form/models/form-field-metadata-value.model.ts index 1aaa8e0bd7a..725cadb7b47 100644 --- a/src/app/core/shared/form/models/form-field-metadata-value.model.ts +++ b/src/app/core/shared/form/models/form-field-metadata-value.model.ts @@ -33,21 +33,26 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { confidence: ConfidenceType; place: number; label: string; + securityLevel: number; + source: string; otherInformation: OtherInformation; constructor(value: any = null, language: any = null, + securityLevel: any = null, authority: string = null, display: string = null, place: number = 0, confidence: number = null, otherInformation: any = null, - metadata: string = null) { + source: string = null, + metadata: string = null, + ) { this.value = isNotNull(value) ? ((typeof value === 'string') ? value.trim() : value) : null; this.language = language; this.authority = authority; this.display = display || value; - + this.securityLevel = securityLevel; this.confidence = confidence; if (Metadata.hasValidAuthority(authority) && (isEmpty(confidence) || confidence === -1)) { this.confidence = ConfidenceType.CF_ACCEPTED; @@ -61,7 +66,7 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { if (isNotEmpty(metadata)) { this.metadata = metadata; } - + this.source = source; this.otherInformation = otherInformation; } @@ -100,6 +105,14 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; } + /** + * Returns true if this object value contains a placeholder + */ + hasSecurityLevel() { + return isNotEmpty(this.securityLevel); + } + + /** * Returns true if this Metadatum's authority key starts with 'virtual::' */ diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index bb08651433c..2082c645e33 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -56,6 +56,10 @@ export class MetadataValue implements MetadataValueInterface { /** The authority confidence value */ @autoserialize confidence: number; + + /** The security level value */ + @autoserialize + securityLevel: number; } /** Constraints for matching metadata values. */ @@ -104,6 +108,9 @@ export class MetadatumViewModel { /** The authority confidence value */ confidence: number; + + /** The security level value */ + securityLevel: number; } /** Serializer used for MetadataMaps. diff --git a/src/app/core/submission/metadatasecurityconfig-data.service.ts b/src/app/core/submission/metadatasecurityconfig-data.service.ts new file mode 100644 index 00000000000..5d73a96077f --- /dev/null +++ b/src/app/core/submission/metadatasecurityconfig-data.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { MetadataSecurityConfiguration } from './models/metadata-security-configuration'; + +/** + * A service that provides methods to make REST requests with securitysettings endpoint. + */ +@Injectable({ + providedIn: 'root', +}) +export class MetadataSecurityConfigurationService extends IdentifiableDataService { + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('securitysettings', requestService, rdbService, objectCache, halService); + } + + /** + * It provides the configuration for metadata security + * @param entityType + */ + findById(entityType: string): Observable> { + return super.findById(entityType); + } +} + diff --git a/src/app/core/submission/models/metadata-security-config.resource-type.ts b/src/app/core/submission/models/metadata-security-config.resource-type.ts new file mode 100644 index 00000000000..825352d5b23 --- /dev/null +++ b/src/app/core/submission/models/metadata-security-config.resource-type.ts @@ -0,0 +1,4 @@ +import { ResourceType } from '../../shared/resource-type'; + + +export const METADATA_SECURITY_TYPE = new ResourceType('securitysetting'); diff --git a/src/app/core/submission/models/metadata-security-configuration.ts b/src/app/core/submission/models/metadata-security-configuration.ts new file mode 100644 index 00000000000..e0dec03aeeb --- /dev/null +++ b/src/app/core/submission/models/metadata-security-configuration.ts @@ -0,0 +1,54 @@ +import { + autoserialize, + deserialize, + deserializeAs, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { METADATA_SECURITY_TYPE } from './metadata-security-config.resource-type'; + +interface MetadataCustomSecurityEntries { + [metadata: string]: number[]; +} +/** + * A model class for a security configuration of metadata. + */ +@typedObject +export class MetadataSecurityConfiguration extends CacheableObject { + static type = METADATA_SECURITY_TYPE; + /** + * The universally unique identifier of this WorkspaceItem + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer(MetadataSecurityConfiguration.type.value), 'id') + uuid: string; + /** + * List of security configurations for all of the metadatas of the entity type + */ + @autoserialize + metadataSecurityDefault: number[]; + /** + * List of security configurations for all of the metadatas of the entity type + */ + @autoserialize + metadataCustomSecurity: MetadataCustomSecurityEntries; + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + /** + * The {@link HALLink}s for this MetadataSecurityConfiguration + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index 035d263e460..20c5c66e6c7 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -22,6 +22,7 @@ import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/rest-request.model'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { FormFieldMetadataValueObject } from '../shared/form/models/form-field-metadata-value.model'; +import { EditItem } from './models/edititem.model'; import { SubmissionObject } from './models/submission-object.model'; import { WorkflowItem } from './models/workflowitem.model'; import { WorkspaceItem } from './models/workspaceitem.model'; @@ -56,6 +57,7 @@ export function normalizeSectionData(obj: any, objIndex?: number) { result = new FormFieldMetadataValueObject( obj.value, obj.language, + obj.securityLevel, obj.authority, (obj.display || obj.value), obj.place || objIndex, @@ -144,7 +146,8 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService // item = Object.assign({}, item); // In case data is an Instance of WorkspaceItem normalize field value of all the section of type form if (item instanceof WorkspaceItem - || item instanceof WorkflowItem) { + || item instanceof WorkflowItem + || item instanceof EditItem) { if (item.sections) { const precessedSection = Object.create({}); // Iterate over all workspaceitem's sections diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts index fb57ad880f2..7cb7aa1c428 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts @@ -45,6 +45,12 @@ export class VocabularyEntry extends ListableObject { @autoserialize otherInformation: OtherInformation; + /** + * A value representing security level value of the metadata + */ + @autoserialize + securityLevel: number; + /** * A string representing the kind of vocabulary entry */ diff --git a/src/app/core/testing/submission-service.stub.ts b/src/app/core/testing/submission-service.stub.ts index da8de737113..4453514f38f 100644 --- a/src/app/core/testing/submission-service.stub.ts +++ b/src/app/core/testing/submission-service.stub.ts @@ -17,6 +17,7 @@ export class SubmissionServiceStub { getDisabledSectionsList = jasmine.createSpy('getDisabledSectionsList'); getSubmissionObjectLinkName = jasmine.createSpy('getSubmissionObjectLinkName'); getSubmissionScope = jasmine.createSpy('getSubmissionScope'); + getSubmissionSecurityConfiguration = jasmine.createSpy('getSubmissionSecurityConfiguration'); getSubmissionStatus = jasmine.createSpy('getSubmissionStatus'); getSubmissionSaveProcessingStatus = jasmine.createSpy('getSubmissionSaveProcessingStatus'); getSubmissionDepositProcessingStatus = jasmine.createSpy('getSubmissionDepositProcessingStatus'); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html index 904a6b962f6..77b3b0d242a 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html @@ -8,12 +8,14 @@ [mdField]="mdField" [dsoType]="dsoType" [saving$]="saving$" + [metadataSecurityConfiguration]="metadataSecurityConfiguration" [isOnlyValue]="form.fields[mdField].length === 1" (edit)="mdValue.editing = true" (confirm)="mdValue.confirmChanges($event); form.resetReinstatable(); valueSaved.emit()" (remove)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; form.resetReinstatable(); valueSaved.emit()" (undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()" - (dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)"> + (dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)" + (updateSecurityLevel)="onUpdateSecurityLevelValue($event, idx)"> } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts index b703c201777..1b591c4118a 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts @@ -12,6 +12,7 @@ import { } from '@angular/core'; import { Context } from '@dspace/core/shared/context.model'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { BehaviorSubject, Observable, @@ -72,6 +73,10 @@ export class DsoEditMetadataFieldValuesComponent { */ @Input() draggingMdField$: BehaviorSubject; + /** + * Security Settings configuration for the current entity + */ + @Input() metadataSecurityConfiguration: MetadataSecurityConfiguration; /** * Emit when the value has been saved within the form */ @@ -106,4 +111,15 @@ export class DsoEditMetadataFieldValuesComponent { this.form.resetReinstatable(); this.valueSaved.emit(); } + + /** + * Update the security level for the field at the given index + */ + onUpdateSecurityLevelValue(securityLevel: number, index: number) { + if (this.form.fields[this.mdField]?.length > 0) { + this.form.fields[this.mdField][index].change = DsoEditMetadataChangeType.UPDATE; + this.form.fields[this.mdField][index].newValue.securityLevel = securityLevel; + this.valueSaved.emit(); + } + } } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index d871cdb9b8a..ae5ae79ef9a 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -18,7 +18,7 @@ class="w-100"> } - @if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE) { + @if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceType.CF_UNSET && mdValue.newValue.confidence !== ConfidenceType.CF_NOVALUE) {
}
+
+
+ @if (canShowMetadataSecurity$ | async) { + + + } +
+
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts index 4a80f28fde6..cd6d1877e52 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -11,14 +11,24 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { RelationshipDataService } from '@dspace/core/data/relationship-data.service'; +import { MetadataField } from '@dspace/core/metadata/metadata-field.model'; +import { MetadataSchema } from '@dspace/core/metadata/metadata-schema.model'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { Collection } from '@dspace/core/shared/collection.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { Item } from '@dspace/core/shared/item.model'; import { MetadataValue, VIRTUAL_METADATA_PREFIX, } from '@dspace/core/shared/metadata.models'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { DsoEditMetadataFieldServiceStub } from '@dspace/core/testing/dso-edit-metadata-field.service.stub'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; +import { RegistryService } from 'src/app/admin/admin-registries/registry/registry.service'; +import { mockSecurityConfig } from 'src/app/submission/utils/submission.mock'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; @@ -44,9 +54,27 @@ describe('DsoEditMetadataValueComponent', () => { let relationshipService: RelationshipDataService; let dsoNameService: DSONameService; let dsoEditMetadataFieldService: DsoEditMetadataFieldServiceStub; - + let registryService: RegistryService; + let notificationsService: NotificationsService; let editMetadataValue: DsoEditMetadataValue; let metadataValue: MetadataValue; + let dso: DSpaceObject; + + const collection = Object.assign(new Collection(), { + uuid: 'fake-uuid', + }); + + const item = Object.assign(new Item(), { + _links: { + self: { href: 'fake-item-url/item' }, + }, + id: 'item', + uuid: 'item', + owningCollection: createSuccessfulRemoteDataObject$(collection), + }); + + let metadataSchema: MetadataSchema; + let metadataFields: MetadataField[]; function initServices(): void { relationshipService = jasmine.createSpyObj('relationshipService', { @@ -58,6 +86,10 @@ describe('DsoEditMetadataValueComponent', () => { getName: 'Related Name', }); dsoEditMetadataFieldService = new DsoEditMetadataFieldServiceStub(); + registryService = jasmine.createSpyObj('registryService', { + queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); } beforeEach(waitForAsync(async () => { @@ -68,6 +100,11 @@ describe('DsoEditMetadataValueComponent', () => { authority: undefined, }); editMetadataValue = new DsoEditMetadataValue(metadataValue); + dso = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'fake-dso-url/dso' }, + }, + }); initServices(); @@ -83,6 +120,8 @@ describe('DsoEditMetadataValueComponent', () => { { provide: RelationshipDataService, useValue: relationshipService }, { provide: DSONameService, useValue: dsoNameService }, { provide: DsoEditMetadataFieldService, useValue: dsoEditMetadataFieldService }, + { provide: RegistryService, useValue: registryService }, + { provide: NotificationsService, useValue: notificationsService }, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -101,8 +140,11 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; + component.dso = dso; + component.metadataSecurityConfiguration = mockSecurityConfig; component.mdField = 'person.birthDate'; component.saving$ = of(false); + spyOn(component, 'initSecurityLevel').and.callThrough(); fixture.detectChanges(); }); @@ -112,6 +154,18 @@ describe('DsoEditMetadataValueComponent', () => { ).toBeNull(); }); + it('should call initSecurityLevel on init', () => { + expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeNull(); + expect(component.initSecurityLevel).toHaveBeenCalled(); + expect(component.mdSecurityConfigLevel$.value).toEqual([0, 1]); + }); + + it('should call initSecurityLevel when field changes', () => { + component.mdField = 'test'; + expect(component.initSecurityLevel).toHaveBeenCalled(); + expect(component.mdSecurityConfigLevel$.value).toEqual([0, 1, 2]); + }); + describe('when no changes have been made', () => { assertButton(EDIT_BTN, true, false); assertButton(CONFIRM_BTN, false); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 66536c3f9b2..65e00fbde5e 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -12,26 +12,41 @@ import { EventEmitter, Input, OnChanges, + OnDestroy, OnInit, Output, SimpleChanges, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { + FormsModule, + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; import { RouterLink } from '@angular/router'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { RelationshipDataService } from '@dspace/core/data/relationship-data.service'; import { MetadataService } from '@dspace/core/metadata/metadata.service'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; import { ConfidenceType } from '@dspace/core/shared/confidence-type'; import { Context } from '@dspace/core/shared/context.model'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { followLink } from '@dspace/core/shared/follow-link-config.model'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { MetadataRepresentation, MetadataRepresentationType, } from '@dspace/core/shared/metadata-representation/metadata-representation.model'; +import { + getFirstCompletedRemoteData, + metadataFieldsToString, +} from '@dspace/core/shared/operators'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; -import { hasValue } from '@dspace/shared/utils/empty.util'; +import { + hasValue, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule, @@ -39,11 +54,22 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, EMPTY, Observable, + of, + Subscription, } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + distinctUntilChanged, + map, + shareReplay, + switchMap, + take, +} from 'rxjs/operators'; +import { RegistryService } from '../../../admin/admin-registries/registry/registry.service'; +import { EditMetadataSecurityComponent } from '../../../item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { AuthorityConfidenceStateDirective } from '../../../shared/form/directives/authority-confidence-state.directive'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; @@ -68,6 +94,7 @@ import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-v CdkDragHandle, DebounceDirective, DsoEditMetadataValueFieldLoaderComponent, + EditMetadataSecurityComponent, FormsModule, NgbTooltip, NgClass, @@ -79,7 +106,7 @@ import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-v /** * Component displaying a single editable row for a metadata value */ -export class DsoEditMetadataValueComponent implements OnInit, OnChanges { +export class DsoEditMetadataValueComponent implements OnInit, OnChanges, OnDestroy { @Input() context: Context; @@ -93,6 +120,20 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { */ @Input() mdValue: DsoEditMetadataValue; + /** + * The metadata security configuration for the entity. + */ + @Input() + set metadataSecurityConfiguration(metadataSecurityConfiguration: MetadataSecurityConfiguration) { + this._metadataSecurityConfiguration$.next(metadataSecurityConfiguration); + } + + get metadataSecurityConfiguration() { + return this._metadataSecurityConfiguration$.value; + } + + protected readonly _metadataSecurityConfiguration$ = + new BehaviorSubject(null); /** * The metadata field to display a value for @@ -156,6 +197,16 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { */ @Output() dragging: EventEmitter = new EventEmitter(); + /** + * Emits the new value of security level + */ + @Output() updateSecurityLevel: EventEmitter = new EventEmitter(); + + /** + * Emits true when the metadata has security settings + */ + @Output() hasSecurityLevel: EventEmitter = new EventEmitter(false); + /** * The DsoEditMetadataChangeType enumeration for access in the component's template * @type {DsoEditMetadataChangeType} @@ -176,19 +227,43 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { * The name of the item represented by this virtual metadata value (otherwise null) */ mdRepresentationName$: Observable; + readonly mdSecurityConfigLevel$: BehaviorSubject = new BehaviorSubject([]); + + canShowMetadataSecurity$: Observable; + + private sub: Subscription; + + /** + * Whether or not the authority field is currently being edited + */ + public editingAuthority = false; + + + /** + * Whether or not the free-text editing is enabled when scrollable dropdown or hierarchical vocabulary is used + */ + public enabledFreeTextEditing = false; + + /** + * Field group used by authority field + * @type {UntypedFormGroup} + */ + group = new UntypedFormGroup({ authorityField : new UntypedFormControl() }); /** * The type of edit field that should be displayed */ fieldType$: Observable; - readonly ConfidenceTypeEnum = ConfidenceType; + readonly ConfidenceType = ConfidenceType; constructor( protected relationshipService: RelationshipDataService, protected dsoNameService: DSONameService, protected metadataService: MetadataService, protected cdr: ChangeDetectorRef, + protected registryService: RegistryService, + protected notificationsService: NotificationsService, protected translate: TranslateService, protected dsoEditMetadataFieldService: DsoEditMetadataFieldService, ) { @@ -196,8 +271,29 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { ngOnInit(): void { this.initVirtualProperties(); + + this.sub = combineLatest([ + this._mdField$, + this._metadataSecurityConfiguration$, + ]).subscribe(([mdField, metadataSecurityConfig]) => this.initSecurityLevel(mdField, metadataSecurityConfig)); + + this.canShowMetadataSecurity$ = + combineLatest([ + this._mdField$.pipe(distinctUntilChanged()), + this.mdSecurityConfigLevel$, + ]).pipe( + map(([mdField, securityConfigLevel]) => hasValue(mdField) && this.hasSecurityChoice(securityConfigLevel)), + shareReplay({ refCount: false, bufferSize: 1 }), + ); } + /** + * Emits the edit event + * @param securityLevel + */ + changeSelectedSecurity(securityLevel: number) { + this.updateSecurityLevel.emit(securityLevel); + } /** * Initialise potential properties of a virtual metadata value @@ -218,6 +314,30 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { ); } + initSecurityLevel(mdField: string, metadataSecurityConfig: MetadataSecurityConfiguration) { + let appliedSecurity: number[] = []; + if (hasValue(metadataSecurityConfig)) { + if (metadataSecurityConfig?.metadataCustomSecurity[mdField]) { + appliedSecurity = metadataSecurityConfig.metadataCustomSecurity[mdField]; + } else if (metadataSecurityConfig?.metadataSecurityDefault) { + appliedSecurity = metadataSecurityConfig.metadataSecurityDefault; + } + } + this.mdSecurityConfigLevel$.next(appliedSecurity); + } + + /** + * Emits the value for the metadata security existence + */ + hasSecurityMetadata(event: boolean) { + this.hasSecurityLevel.emit(event); + } + + private hasSecurityChoice(securityConfigLevel: number[]) { + return securityConfigLevel?.length > 1; + } + + /** * Retrieves the {@link EditMetadataValueFieldType} to be displayed for the current field while in edit mode. */ @@ -242,8 +362,53 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { * @param {SimpleChanges} changes */ ngOnChanges(changes: SimpleChanges): void { - if (changes.mdField) { - this.fieldType$ = this.getFieldType(); + if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { + if (isNotEmpty(changes.mdField.currentValue) ) { + if (isNotEmpty(changes.mdField.previousValue) && + changes.mdField.previousValue !== changes.mdField.currentValue) { + // Clear authority value in case it has been assigned with the previous metadataField used + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + + // Only ask if the current mdField have a period character to reduce request + if (changes.mdField.currentValue.includes('.')) { + this.validateMetadataField().subscribe((isValid: boolean) => { + if (isValid) { + this.cdr.detectChanges(); + } + }); + } + } + } + } + + /** + * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error + */ + validateMetadataField(): Observable { + return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd) => { + if (rd.hasSucceeded) { + return of(rd).pipe( + metadataFieldsToString(), + take(1), + map((fields: string[]) => fields.indexOf(this.mdField) > -1), + ); + } else { + this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); + return [false]; + } + }), + ); + } + + + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); } } + } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index d9f62d2a5f4..d2a59061a36 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -47,6 +47,7 @@ [dsoType]="dsoType"> + (undo)="form.newValue = undefined" + (updateSecurityLevel)="onUpdateSecurityLevel($event)" + (hasSecurityLevel)="hasSecurityLevel($event)">
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts index c7d82285be1..2e8387cced8 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts @@ -20,8 +20,12 @@ import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { Item } from '@dspace/core/shared/item.model'; import { ITEM } from '@dspace/core/shared/item.resource-type'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { TestDataService } from '@dspace/core/testing/test-data-service.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { TranslateModule } from '@ngx-translate/core'; +import { mockSecurityConfig } from 'src/app/submission/utils/submission.mock'; import { AlertComponent } from '../../shared/alert/alert.component'; import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; @@ -43,6 +47,10 @@ const mockDataServiceMap: any = new Map([ [ITEM.value, () => import('@dspace/core/testing/test-data-service.mock').then(m => m.TestDataService)], ]); +const metadataSecurityConfigDataServiceSpy = jasmine.createSpyObj('metadataSecurityConfigDataService', { + findById: createSuccessfulRemoteDataObject$(mockSecurityConfig), +}); + describe('DsoEditMetadataComponent', () => { let component: DsoEditMetadataComponent; let fixture: ComponentFixture; @@ -100,6 +108,7 @@ describe('DsoEditMetadataComponent', () => { providers: [ { provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap }, { provide: NotificationsService, useValue: notificationsService }, + { provide: MetadataSecurityConfigurationService, useValue: metadataSecurityConfigDataServiceSpy }, ArrayMoveChangeAnalyzer, TestDataService, ], @@ -128,6 +137,10 @@ describe('DsoEditMetadataComponent', () => { fixture.detectChanges(); })); + it('should set security configuration object', () => { + expect(component.securitySettings$.value).toEqual(mockSecurityConfig); + }); + describe('when no changes have been made', () => { assertButton(ADD_BTN, true, false); assertButton(REINSTATE_BTN, false); @@ -202,20 +215,35 @@ describe('DsoEditMetadataComponent', () => { expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeNull(); }); }); + + it('should fetch security settings for Item', () => { + component.dso = Object.assign(new Item(), { + ...dso, + entityType: 'Person', + }); + component.getSecuritySettings().subscribe((securitySettings: MetadataSecurityConfiguration) => { + expect(securitySettings).toBeDefined(); + }); + }); }); function assertButton(name: string, exists: boolean, disabled: boolean = false): void { describe(`${name} button`, () => { let btn: DebugElement; - beforeEach(() => { + beforeEach(waitForAsync(() => { + fixture.detectChanges(); btn = fixture.debugElement.query(By.css(`#dso-${name}-btn`)); - }); + })); if (exists) { - it('should exist', () => { + it('form should be initialized', waitForAsync(() => { + expect(component.isFormInitialized$.value).toBeTrue(); + })); + + it('should exist', waitForAsync(() => { expect(btn).toBeTruthy(); - }); + })); it(`should${disabled ? ' ' : ' not '}be disabled`, () => { if (disabled) { @@ -227,9 +255,9 @@ describe('DsoEditMetadataComponent', () => { } }); } else { - it('should not exist', () => { + it('should not exist', waitForAsync(() => { expect(btn).toBeNull(); - }); + })); } }); } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts index 6dc5d4abc89..5590c71c065 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -13,7 +13,9 @@ import { ActivatedRoute, Data, } from '@angular/router'; +import { DATA_SERVICE_FACTORY } from '@dspace/core/cache/builders/build-decorators'; import { ArrayMoveChangeAnalyzer } from '@dspace/core/data/array-move-change-analyzer.service'; +import { HALDataService } from '@dspace/core/data/base/hal-data-service.interface'; import { RemoteData } from '@dspace/core/data/remote-data'; import { UpdateDataService } from '@dspace/core/data/update-data.service'; import { @@ -24,8 +26,12 @@ import { lazyDataService } from '@dspace/core/lazy-data-service'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { Context } from '@dspace/core/shared/context.model'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { GenericConstructor } from '@dspace/core/shared/generic-constructor'; +import { Item } from '@dspace/core/shared/item.model'; import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; import { ResourceType } from '@dspace/core/shared/resource-type'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { hasNoValue, hasValue, @@ -37,6 +43,7 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, combineLatest as observableCombineLatest, Observable, of, @@ -44,7 +51,7 @@ import { } from 'rxjs'; import { map, - mergeMap, + switchMap, tap, } from 'rxjs/operators'; @@ -53,7 +60,10 @@ import { AlertType } from '../../shared/alert/alert-type'; import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata-field-values/dso-edit-metadata-field-values.component'; -import { DsoEditMetadataForm } from './dso-edit-metadata-form'; +import { + DsoEditMetadataChangeType, + DsoEditMetadataForm, +} from './dso-edit-metadata-form'; import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata-headers/dso-edit-metadata-headers.component'; import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value/dso-edit-metadata-value.component'; import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component'; @@ -153,6 +163,28 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { */ dsoUpdateSubscription: Subscription; + /** + * Field to keep track of the current security level + * in case a new mdField is added and the security level needs to be set + */ + newMdFieldWithSecurityLevelValue: number; + + /** + * Flag to indicate if the metadata security configuration is present + * for the newly added metadata field + */ + hasSecurityMetadata = false; + + /** + * Contains metadata security configuration object + */ + isFormInitialized$: BehaviorSubject = new BehaviorSubject(false); + + /** + * Contains metadata security configuration object + */ + securitySettings$: BehaviorSubject = new BehaviorSubject(null); + public readonly Context = Context; constructor(protected route: ActivatedRoute, @@ -161,7 +193,9 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { protected parentInjector: Injector, protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer, protected cdr: ChangeDetectorRef, - @Inject(APP_DATA_SERVICES_MAP) private dataServiceMap: LazyDataServicesMap) { + @Inject(APP_DATA_SERVICES_MAP) private dataServiceMap: LazyDataServicesMap, + protected metadataSecurityConfigurationService: MetadataSecurityConfigurationService, + @Inject(DATA_SERVICE_FACTORY) protected getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>) { } /** @@ -172,24 +206,47 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { if (hasNoValue(this.dso)) { this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe( map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)), - tap((data: any) => this.initDSO(data.dso.payload)), - mergeMap(() => this.retrieveDataService()), - ).subscribe((dataService: UpdateDataService) => { + tap((data: any) => this.initDSO(data.dso.payload)), + switchMap(() => combineLatest([this.retrieveDataService(),this.getSecuritySettings()])), + ).subscribe(([dataService, securitySettings]: [UpdateDataService, MetadataSecurityConfiguration]) => { + this.securitySettings$.next(securitySettings); this.initDataService(dataService); this.initForm(); + this.isFormInitialized$.next(true); }); } else { this.initDSOType(this.dso); - this.retrieveDataService().subscribe((dataService: UpdateDataService) => { - this.initDataService(dataService); - this.initForm(); - }); + observableCombineLatest([this.retrieveDataService(), this.getSecuritySettings()]) + .subscribe(([dataService, securitySettings]: [UpdateDataService, MetadataSecurityConfiguration]) => { + this.securitySettings$.next(securitySettings); + this.initDataService(dataService); + this.initForm(); + this.isFormInitialized$.next(true); + }); } this.savingOrLoadingFieldValidation$ = observableCombineLatest([this.saving$, this.loadingFieldValidation$]).pipe( map(([saving, loading]: [boolean, boolean]) => saving || loading), ); } + /** + * Get the security settings for the current DSpaceObject, + * based on entityType (e.g. Person) + */ + getSecuritySettings(): Observable { + if (this.dso instanceof Item) { + const entityType: string = (this.dso as Item).entityType; + return this.metadataSecurityConfigurationService.findById(entityType).pipe( + getFirstCompletedRemoteData(), + map((securitySettingsRD: RemoteData) => { + return securitySettingsRD.hasSucceeded ? securitySettingsRD.payload : null; + }), + ); + } else { + of(null); + } + } + /** * Resolve the data-service for the current DSpaceObject and retrieve its instance */ @@ -297,6 +354,7 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { this.loadingFieldValidation$.next(false); if (valid) { this.form.setMetadataField(this.newMdField); + this.setSecurityLevelForNewMdField(); this.onValueSaved(); } }); @@ -326,6 +384,74 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { this.onValueSaved(); } + /** + * Keep track of the metadata field that is currently being edited / added + * Reset the security level properties for the new metadata field + * @param value The value of the new metadata field + */ + onMdFieldChange(value: string){ + if (hasValue(value)) { + this.newMdFieldWithSecurityLevelValue = null; + this.hasSecurityMetadata = false; + } + } + + /** + * Update the security level for the field at the given index + */ + onUpdateSecurityLevel(securityLevel: number) { + this.setSecurityLevelForNewMdField(securityLevel); + } + + /** + * Set the security level for the new metadata field + * If the new metadata field has no security level yet, store the security level in a temporary variable + * until the metadata field is validated and set. + * @param securityLevel The security level to set for the new metadata field + */ + setSecurityLevelForNewMdField(securityLevel?: number) { + // if the metadata field already exists among the metadata fields, + // set the security level for the new metadata field in the right position + if (hasValue(this.newMdField) && hasValue(this.form.fields[this.newMdField]) && this.hasSecurityMetadata) { + const lastIndex = this.form.fields[this.newMdField].length - 1; + const obj = this.form.fields[this.newMdField][lastIndex]; + + if (hasValue(securityLevel)) { + // metadata field is not set yet, so store the security level for the new metadata field + this.newMdFieldWithSecurityLevelValue = securityLevel; + } else { + // metadata field is set, so set the security level for the new metadata field + obj.change = DsoEditMetadataChangeType.ADD; + const customSecurity = this.securitySettings$.value.metadataCustomSecurity[this.newMdField]; + const lastCustomSecurityLevel = customSecurity[customSecurity.length - 1]; + + obj.newValue.securityLevel = this.newMdFieldWithSecurityLevelValue ?? lastCustomSecurityLevel; + } + } + + // if the security level value is changed before the metadata field is set, + // store the security level in a temporary variable + if (hasValue(securityLevel) && hasNoValue(this.form.fields[this.newMdField])) { + this.newMdFieldWithSecurityLevelValue = securityLevel; + } + + if (!this.hasSecurityMetadata) { + // for newly added metadata fields, set the security level to the default security level + // (in case there is no custom security level for the metadata field) + const defaultSecurity = this.securitySettings$.value.metadataSecurityDefault; + const lastDefaultSecurityLevel = defaultSecurity[defaultSecurity.length - 1]; + + this.form.fields[this.newMdField][this.form.fields[this.newMdField].length - 1].newValue.securityLevel = lastDefaultSecurityLevel; + } + } + + /** + * Check if the new metadata field has a security level + */ + hasSecurityLevel(event: boolean) { + this.hasSecurityMetadata = event; + } + /** * Unsubscribe from any open subscriptions */ diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index b4461df18a3..fe2de784086 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -100,4 +100,4 @@
Footer Content
}
- \ No newline at end of file + diff --git a/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.html b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.html new file mode 100644 index 00000000000..5a93323937c --- /dev/null +++ b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.html @@ -0,0 +1,21 @@ +@if (securityConfigLevel?.length > 1) { +
+ @for (secLevel of securityLevelsMap; track secLevel.value) { +
+
+ +
+
+ } + +
+} + + diff --git a/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.scss b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.scss new file mode 100644 index 00000000000..d889862bf7d --- /dev/null +++ b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.scss @@ -0,0 +1,31 @@ +.switch-container { + height: 35px; + width: fit-content; + background-color: rgb(204, 204, 204); + border-radius: 100px; + display: flex; + + //display: flex; + //flex-wrap: wrap; + //flex: 50%; +} + +.switch-opt { + align-items: center; + display: flex; + justify-content: center; + background-color: rgb(204, 204, 204); + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 100%; + position: relative; + top: 1px; + margin-left: 2px; + margin-right: 2px; +} + +.btn-link:focus { + outline: none !important; + box-shadow: none !important; +} diff --git a/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.spec.ts b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.spec.ts new file mode 100644 index 00000000000..02827e50ab9 --- /dev/null +++ b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.spec.ts @@ -0,0 +1,100 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { EditMetadataSecurityComponent } from './edit-metadata-security.component'; + +describe('EditMetadataSecurityComponent', () => { + let component: EditMetadataSecurityComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditMetadataSecurityComponent], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(EditMetadataSecurityComponent); + component = fixture.componentInstance; + }); + + describe('when security levels are defined', () => { + + beforeEach(() => { + component.securityConfigLevel = [0, 1, 2]; + }); + + describe('and security level is given', () => { + beforeEach(() => { + component.securityLevel = 1; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the switch buttons', () => { + const btns = fixture.debugElement.queryAll(By.css('[data-test="switch-btn"]')); + expect(btns.length).toBe(3); + }); + }); + + describe('and security level is not given and is a new field', () => { + beforeEach(() => { + component.isNewMdField = true; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the switch buttons', () => { + const btns = fixture.debugElement.queryAll(By.css('[data-test="switch-btn"]')); + expect(btns.length).toBe(3); + }); + + it('should init security', () => { + expect(component.securityLevel).toBe(2); + }); + }); + + describe('and security level is not given and is not a new field', () => { + beforeEach(() => { + component.isNewMdField = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the switch buttons', () => { + const btns = fixture.debugElement.queryAll(By.css('[data-test="switch-btn"]')); + expect(btns.length).toBe(3); + }); + + it('should init security', () => { + expect(component.securityLevel).toBe(0); + }); + }); + }); + + describe('when security levels are not defined', () => { + + beforeEach(() => { + component.securityConfigLevel = []; + fixture.detectChanges(); + }); + + it('should not render the switch buttons', () => { + const btns = fixture.debugElement.queryAll(By.css('[data-test="switch-btn"]')); + expect(btns.length).toBe(0); + }); + }); +}); diff --git a/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.ts b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.ts new file mode 100644 index 00000000000..5c3b012b66a --- /dev/null +++ b/src/app/item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component.ts @@ -0,0 +1,106 @@ +import { NgStyle } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { LevelSecurityConfig } from '@dspace/config/metadata-security-config'; +import { + hasNoValue, + isEmpty, +} from '@dspace/shared/utils/empty.util'; +import { BtnDisabledDirective } from 'src/app/shared/btn-disabled.directive'; + +import { environment } from '../../../../environments/environment'; + +@Component({ + selector: 'ds-edit-metadata-security', + templateUrl: './edit-metadata-security.component.html', + styleUrls: ['./edit-metadata-security.component.scss'], + imports: [ + BtnDisabledDirective, + NgStyle, + ], +}) +export class EditMetadataSecurityComponent implements OnInit { + + /** + * A boolean representing if toggle buttons should be disabled + */ + @Input() readOnly = false; + + /** + * The start security value + */ + @Input() securityLevel: number; + + /** + * The security levels available + */ + @Input() securityConfigLevel: number[] = []; + + /** + * A boolean representing if security toggle is related to a new field + */ + @Input() isNewMdField = false; + + /** + * An event emitted when the security level is changed by the user + */ + @Output() changeSecurityLevel = new EventEmitter(); + + /** + * Emits when a metadata field has a security level configuration + */ + @Output() hasSecurityLevel = new EventEmitter(); + + public securityLevelsMap: LevelSecurityConfig[] = environment.security.levels; + + ngOnInit(): void { + this.filterSecurityLevelsMap(); + this.hasSecurityLevel.emit(true); + + if (this.securityConfigLevel.length > 0) { + if (this.isNewMdField) { + // If the metadata field is new, set the security level to the highest level automatically + this.securityLevel = this.securityConfigLevel[this.securityConfigLevel.length - 1]; + } else if (isEmpty(this.securityLevel)) { + // If the metadata field is existing but has no security value, set the security level to the lowest level automatically + this.securityLevel = this.securityConfigLevel[0]; + } + } + } + + /** + * Check if the selected security level is different from the current level, + * if so,update the security level & emit the new level + * @param level The security level to change to + */ + changeSelectedSecurityLevel(level: number) { + if (this.securityLevel !== level) { + this.securityLevel = level; + this.changeSecurityLevel.emit(level); + } + } + + private filterSecurityLevelsMap() { + this.securityLevelsMap = environment.security.levels; + if ( + hasNoValue(this.securityConfigLevel) || + (this.securityConfigLevel.length === 1 && + this.securityConfigLevel.includes(0)) + ) { + this.securityLevelsMap = null; + this.changeSecurityLevel.emit(0); + } else { + // Filter securityLevelsMap based on securityConfigLevel + this.securityLevelsMap = this.securityLevelsMap.filter( + (el: any, index) => { + return index === 0 || this.securityConfigLevel.includes(el.value); + }, + ); + } + } +} 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 1365c88b270..3d96f4c08f8 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 @@ -12,7 +12,7 @@ } -
0)}">
@@ -60,6 +60,17 @@
} + @if (model.hasSecurityToggle) { +
+ @if (model.toggleSecurityVisibility) { + + + } +
+ } @if (isRelationship) {
diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index b00d0bdbb6e..eb0eecfee9c 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -11,10 +11,12 @@ import { } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { AuthService } from '@dspace/core/auth/auth.service'; +import { CollectionDataService } from '@dspace/core/data/collection-data.service'; import { ItemDataService } from '@dspace/core/data/item-data.service'; import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; import { SubmissionJsonPatchOperationsService } from '@dspace/core/submission/submission-json-patch-operations.service'; import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub'; @@ -36,6 +38,7 @@ import { SectionsService } from '../sections/sections.service'; import { SubmissionService } from '../submission.service'; import { mockSubmissionObject } from '../utils/submission.mock'; import { SubmissionEditComponent } from './submission-edit.component'; +import { SubmissionEditCanDeactivateService } from './submission-edit-can-deactivate.service'; describe('SubmissionEditComponent Component', () => { @@ -43,6 +46,9 @@ describe('SubmissionEditComponent Component', () => { let fixture: ComponentFixture; let submissionServiceStub: SubmissionServiceStub; let itemDataService: ItemDataService; + let metadataSecurityConfigDataService: MetadataSecurityConfigurationService; + let canDeactivateService: SubmissionEditCanDeactivateService; + let collectionDataService: CollectionDataService; let submissionJsonPatchOperationsServiceStub: SubmissionJsonPatchOperationsServiceStub; let router: RouterStub; let halService: jasmine.SpyObj; @@ -59,14 +65,29 @@ describe('SubmissionEditComponent Component', () => { }, }); - beforeEach(waitForAsync(() => { - itemDataService = jasmine.createSpyObj('itemDataService', { - findByHref: createSuccessfulRemoteDataObject$(submissionObject.item), - }); + const collectionDataServiceSpy: jasmine.SpyObj = jasmine.createSpyObj('collectionDataService', { + findById: jasmine.createSpy('findById'), + getAuthorizedCollectionByCommunity: jasmine.createSpy('getAuthorizedCollectionByCommunity'), + getAuthorizedCollectionByCommunityAndEntityType: jasmine.createSpy('getAuthorizedCollectionByCommunityAndEntityType'), + }); + + const canDeactivateServiceSpy: jasmine.SpyObj = jasmine.createSpyObj('canDeactivateService', { + canDeactivate: of(true), + }); + const itemDataServiceSpy = jasmine.createSpyObj('itemDataService', { + findByHref: createSuccessfulRemoteDataObject$(submissionObject.item), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of('fake-url'), + }); + + const metadataSecurityConfigDataServiceSpy = jasmine.createSpyObj('metadataSecurityConfigDataService', { + findById: createSuccessfulRemoteDataObject$(submissionObject.metadataSecurityConfiguration), + }); - halService = jasmine.createSpyObj('halService', { - getEndpoint: of('fake-url'), - }); + + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -80,7 +101,9 @@ describe('SubmissionEditComponent Component', () => { { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: SubmissionService, useClass: SubmissionServiceStub }, { provide: SubmissionJsonPatchOperationsService, useClass: SubmissionJsonPatchOperationsServiceStub }, - { provide: ItemDataService, useValue: itemDataService }, + { provide: ItemDataService, useValue: itemDataServiceSpy }, + { provide: MetadataSecurityConfigurationService, useValue: metadataSecurityConfigDataServiceSpy }, + { provide: CollectionDataService, useValue: collectionDataServiceSpy }, { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: route }, { provide: AuthService, useValue: new AuthServiceStub() }, @@ -90,6 +113,7 @@ describe('SubmissionEditComponent Component', () => { { provide: XSRFService, useValue: {} }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, provideMockStore(), + { provide: SubmissionEditCanDeactivateService, useValue: canDeactivateServiceSpy }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(SubmissionEditComponent, { @@ -105,6 +129,10 @@ describe('SubmissionEditComponent Component', () => { submissionServiceStub = TestBed.inject(SubmissionService as any); submissionJsonPatchOperationsServiceStub = TestBed.inject(SubmissionJsonPatchOperationsService as any); router = TestBed.inject(Router as any); + canDeactivateService = TestBed.inject(SubmissionEditCanDeactivateService); + collectionDataService = TestBed.inject(CollectionDataService); + itemDataService = TestBed.inject(ItemDataService); + metadataSecurityConfigDataService = TestBed.inject(MetadataSecurityConfigurationService); }); afterEach(() => { @@ -174,5 +202,4 @@ describe('SubmissionEditComponent Component', () => { }); - }); diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 94b7499249b..0af96a7ae65 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectorRef, Component, + HostListener, OnDestroy, OnInit, } from '@angular/core'; @@ -15,31 +16,42 @@ import { RemoteData } from '@dspace/core/data/remote-data'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { Collection } from '@dspace/core/shared/collection.model'; import { Item } from '@dspace/core/shared/item.model'; -import { getAllSucceededRemoteData } from '@dspace/core/shared/operators'; +import { + getAllSucceededRemoteData, + getFirstCompletedRemoteData, +} from '@dspace/core/shared/operators'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { SubmissionError } from '@dspace/core/submission/models/submission-error.model'; import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; import { WorkspaceitemSectionsObject } from '@dspace/core/submission/models/workspaceitem-sections.model'; import { SubmissionJsonPatchOperationsService } from '@dspace/core/submission/submission-json-patch-operations.service'; +import { createFailedRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { hasValue, isEmpty, + isNotEmpty, isNotEmptyOperator, isNotNull, } from '@dspace/shared/utils/empty.util'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, + of, Subscription, } from 'rxjs'; import { debounceTime, filter, + mergeMap, switchMap, } from 'rxjs/operators'; import { ThemedSubmissionFormComponent } from '../form/themed-submission-form.component'; import { SubmissionService } from '../submission.service'; import parseSectionErrors from '../utils/parseSectionErrors'; +import { SubmissionEditCanDeactivateService } from './submission-edit-can-deactivate.service'; /** * This component allows to edit an existing workspaceitem/workflowitem. @@ -118,6 +130,12 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { * The item for this submission. */ public item: Item; + /** + * The metadata security configuration for the entity. + */ + public metadataSecurityConfiguration: MetadataSecurityConfiguration; + + private canDeactivate = false; /** * Initialize instance variables @@ -130,15 +148,27 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { * @param {SubmissionService} submissionService * @param {TranslateService} translate * @param {SubmissionJsonPatchOperationsService} submissionJsonPatchOperationsService + * @param metadataSecurityConfigDataService + * @param canDeactivateService */ - constructor(private changeDetectorRef: ChangeDetectorRef, - private notificationsService: NotificationsService, - private route: ActivatedRoute, - private router: Router, - private itemDataService: ItemDataService, - private submissionService: SubmissionService, - private translate: TranslateService, - private submissionJsonPatchOperationsService: SubmissionJsonPatchOperationsService) { + constructor( + private changeDetectorRef: ChangeDetectorRef, + private notificationsService: NotificationsService, + private route: ActivatedRoute, + private router: Router, + private itemDataService: ItemDataService, + private submissionService: SubmissionService, + private translate: TranslateService, + private submissionJsonPatchOperationsService: SubmissionJsonPatchOperationsService, + private metadataSecurityConfigDataService: MetadataSecurityConfigurationService, + private canDeactivateService: SubmissionEditCanDeactivateService, + ) { + } + + // @HostListener allows to also guard against browser refresh, close, etc. + @HostListener('window:beforeunload') + preventRefresh(): boolean { + return this.canDeactivate; } /** @@ -147,18 +177,46 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { ngOnInit() { this.collectionModifiable = this.route.snapshot.data?.collectionModifiable ?? null; - // TODO: add all languages projection + this.subs.push( this.route.paramMap.pipe( - switchMap((params: ParamMap) => this.submissionService.retrieveSubmission(params.get('id'))), - // NOTE new submission is retrieved on the browser side only, so get null on server side rendering - filter((submissionObjectRD: RemoteData) => isNotNull(submissionObjectRD)), - ).subscribe((submissionObjectRD: RemoteData) => { + switchMap((params: ParamMap) => this.canDeactivateService.canDeactivate(params.get('id'))), + ).subscribe((res) => { + this.canDeactivate = res; + }), + this.route.paramMap.pipe( + switchMap((params: ParamMap) => this.submissionService.retrieveSubmission(params.get('id'), ['allLanguages']).pipe( + // NOTE new submission is retrieved on the browser side only, so get null on server side rendering + filter((submissionObjectRD: RemoteData) => isNotNull(submissionObjectRD)), + mergeMap((submissionObjectRD: RemoteData) => combineLatest([ + of(submissionObjectRD), + of(submissionObjectRD).pipe( + mergeMap(() => { + if (submissionObjectRD.hasSucceeded && isNotEmpty(submissionObjectRD.payload)) { + const metadata = (submissionObjectRD.payload.collection as Collection).metadata['dspace.entity.type']; + if (metadata && metadata[0]) { + this.entityType = metadata[0].value; + } + // get security configuration based on entity type + return this.metadataSecurityConfigDataService.findById(this.entityType).pipe( + getFirstCompletedRemoteData(), + ); + } else { + return createFailedRemoteDataObject$(); + } + }), + ), + ]), + ))), + ).subscribe(([submissionObjectRD, metadataSecurityRD]: [RemoteData, RemoteData]) => { if (submissionObjectRD.hasSucceeded) { if (isEmpty(submissionObjectRD.payload)) { this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); this.router.navigate(['/mydspace']); } else { + if (metadataSecurityRD.hasSucceeded) { + this.metadataSecurityConfiguration = metadataSecurityRD.payload; + } const collection = submissionObjectRD.payload.collection as Collection; this.entityType = (hasValue(collection) && collection.hasMetadata('dspace.entity.type')) ? collection.firstMetadataValue('dspace.entity.type') : null; diff --git a/src/app/submission/form/submission-form.component.spec.ts b/src/app/submission/form/submission-form.component.spec.ts index 66d31e23bf2..d686ec5f29e 100644 --- a/src/app/submission/form/submission-form.component.spec.ts +++ b/src/app/submission/form/submission-form.component.spec.ts @@ -12,11 +12,13 @@ import { import { AuthService } from '@dspace/core/auth/auth.service'; import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; import { Item } from '@dspace/core/shared/item.model'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; import { VisibilityType } from '@dspace/core/submission/visibility-type'; import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub'; import { HALEndpointServiceStub } from '@dspace/core/testing/hal-endpoint-service.stub'; import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; import { createTestComponent } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { TranslateModule } from '@ngx-translate/core'; import { cold, @@ -35,6 +37,7 @@ import { mockSubmissionCollectionId, mockSubmissionDefinition, mockSubmissionId, + mockSubmissionObject, mockSubmissionObjectNew, mockSubmissionSelfUrl, mockSubmissionState, @@ -52,7 +55,9 @@ describe('SubmissionFormComponent', () => { let fixture: ComponentFixture; let authServiceStub: AuthServiceStub; let scheduler: TestScheduler; + let metadataSecurityConfigDataService: MetadataSecurityConfigurationService; + const submissionObject: any = mockSubmissionObject; const submissionServiceStub: SubmissionServiceStub = new SubmissionServiceStub(); const submissionId = mockSubmissionId; const collectionId = mockSubmissionCollectionId; @@ -64,6 +69,9 @@ describe('SubmissionFormComponent', () => { const sectionsData: any = mockSectionsData; beforeEach(waitForAsync(() => { + metadataSecurityConfigDataService = jasmine.createSpyObj('metadataSecurityConfigDataService', { + findById: createSuccessfulRemoteDataObject$(submissionObject.metadataSecurityConfiguration), + }); TestBed.configureTestingModule({ imports: [ SubmissionFormComponent, @@ -74,6 +82,7 @@ describe('SubmissionFormComponent', () => { { provide: AuthService, useClass: AuthServiceStub }, { provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') }, { provide: SubmissionService, useValue: submissionServiceStub }, + { provide: MetadataSecurityConfigurationService, useValue: metadataSecurityConfigDataService }, { provide: SectionsService, useValue: { isSectionTypeAvailable: () => of(true) } }, ChangeDetectorRef, SubmissionFormComponent, @@ -158,7 +167,7 @@ describe('SubmissionFormComponent', () => { comp.sections = sectionsData; comp.submissionErrors = null; comp.item = new Item(); - + comp.entityType = 'publication'; submissionServiceStub.getSubmissionObject.and.returnValue(of(submissionState)); submissionServiceStub.getSubmissionSections.and.returnValue(of(sectionsList)); spyOn(authServiceStub, 'buildAuthHeader').and.returnValue('token'); @@ -181,7 +190,8 @@ describe('SubmissionFormComponent', () => { submissionDefinition, sectionsData, comp.item, - null); + null, + undefined); expect(submissionServiceStub.startAutoSave).toHaveBeenCalled(); done(); }); @@ -219,6 +229,7 @@ describe('SubmissionFormComponent', () => { comp.selfUrl = selfUrl; comp.sections = sectionsData; comp.item = new Item(); + comp.entityType = 'publication'; scheduler.schedule(() => { comp.onCollectionChange(submissionObjectNew); @@ -237,6 +248,7 @@ describe('SubmissionFormComponent', () => { submissionObjectNew.submissionDefinition, submissionObjectNew.sections, comp.item, + submissionObject.metadataSecurityConfiguration, ); done(); }); @@ -249,6 +261,7 @@ describe('SubmissionFormComponent', () => { comp.selfUrl = selfUrl; comp.sections = sectionsData; comp.item = new Item(); + comp.entityType = 'publication'; scheduler.schedule(() => { comp.onCollectionChange({ diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 89ca7286df0..06b0406bafd 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -13,6 +13,9 @@ import { SubmissionSectionModel } from '@dspace/core/config/models/config-submis import { Collection } from '@dspace/core/shared/collection.model'; import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { SectionVisibility } from '@dspace/core/submission/models/section-visibility.model'; import { SubmissionError } from '@dspace/core/submission/models/submission-error.model'; import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; @@ -114,7 +117,11 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * @type {string} */ @Input() submissionId: string; - + /** + * The metadata security config based on the entity type + * @type {MetadataSecurityConfiguration} + */ + @Input() metadataSecurityConfiguration: MetadataSecurityConfiguration; /** * The entity type input used to create a new submission * @type {string} @@ -170,13 +177,15 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * @param {HALEndpointService} halService * @param {SubmissionService} submissionService * @param {SectionsService} sectionsService + * @param metadataSecurityConfigDataService */ constructor( private authService: AuthService, private changeDetectorRef: ChangeDetectorRef, private halService: HALEndpointService, private submissionService: SubmissionService, - private sectionsService: SectionsService) { + private sectionsService: SectionsService, + private metadataSecurityConfigDataService: MetadataSecurityConfigurationService) { this.isActive = true; } @@ -226,7 +235,8 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { this.submissionDefinition, this.sections, this.item, - this.submissionErrors); + this.submissionErrors, + this.metadataSecurityConfiguration); this.changeDetectorRef.detectChanges(); }), ); @@ -293,20 +303,32 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * new submission object */ onCollectionChange(submissionObject: SubmissionObject) { - if (this.definitionId !== (submissionObject.submissionDefinition as SubmissionDefinitionsModel).name) { - this.sections = submissionObject.sections; - this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); - this.definitionId = this.submissionDefinition.name; - this.submissionService.resetSubmissionObject( - (submissionObject.collection as Collection).id, - this.submissionId, - submissionObject._links.self.href, - this.submissionDefinition, - this.sections, - this.item); - } else { - this.changeDetectorRef.detectChanges(); + const metadata = (submissionObject.collection as Collection).metadata ? (submissionObject.collection as Collection).metadata['dspace.entity.type'] : null; + if (metadata && metadata[0]) { + this.entityType = metadata[0].value; } + this.metadataSecurityConfigDataService.findById(this.entityType).pipe( + getFirstCompletedRemoteData(), + ).subscribe(res => { + this.metadataSecurityConfiguration = res.payload; + this.collectionId = (submissionObject.collection as Collection).id; + if (this.definitionId !== (submissionObject.submissionDefinition as SubmissionDefinitionsModel).name) { + this.sections = submissionObject.sections; + this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); + this.definitionId = this.submissionDefinition.name; + this.submissionService.resetSubmissionObject( + (submissionObject.collection as Collection).id, + this.submissionId, + submissionObject._links.self.href, + this.submissionDefinition, + this.sections, + this.item, + this.metadataSecurityConfiguration, + ); + } else { + this.changeDetectorRef.detectChanges(); + } + }); } protected getSectionsList(): Observable { diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts b/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts index 1673faa7828..cca72508de3 100644 --- a/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts @@ -36,7 +36,6 @@ import { mockSubmissionCollectionId, mockSubmissionId, mockSubmissionObject, - mockUploadResponse1ParsedErrors, mockUploadResponse2Errors, mockUploadResponse2ParsedErrors, } from '../../utils/submission.mock'; @@ -164,16 +163,19 @@ describe('SubmissionUploadFilesComponent Component', () => { }); it('should show a success notification and call updateSectionData if successful', () => { - const expectedErrors: any = mockUploadResponse1ParsedErrors; + const expectedErrors: any = []; fixture.detectChanges(); + const data = { + upload: { + files: [{ url: 'testUrl' }], + } }; + comp.onCompleteItem(Object.assign({}, uploadRestResponse, { sections: data })); - comp.onCompleteItem(Object.assign({}, uploadRestResponse, { sections: mockSectionsData })); - - Object.keys(mockSectionsData).forEach((sectionId) => { + Object.keys(data).forEach((sectionId) => { expect(sectionsServiceStub.updateSectionData).toHaveBeenCalledWith( submissionId, sectionId, - mockSectionsData[sectionId], + data[sectionId], expectedErrors[sectionId], expectedErrors[sectionId], ); diff --git a/src/app/submission/form/themed-submission-form.component.ts b/src/app/submission/form/themed-submission-form.component.ts index 20c4d375276..9a8afbcb24b 100644 --- a/src/app/submission/form/themed-submission-form.component.ts +++ b/src/app/submission/form/themed-submission-form.component.ts @@ -4,6 +4,7 @@ import { } from '@angular/core'; import { SubmissionDefinitionsModel } from '@dspace/core/config/models/config-submission-definitions.model'; import { Item } from '@dspace/core/shared/item.model'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { SubmissionError } from '@dspace/core/submission/models/submission-error.model'; import { WorkspaceitemSectionsObject } from '@dspace/core/submission/models/workspaceitem-sections.model'; @@ -33,7 +34,9 @@ export class ThemedSubmissionFormComponent extends ThemedComponent { { provide: 'collectionIdProvider', useValue: collectionId }, { provide: 'sectionDataProvider', useValue: Object.assign({}, sectionObject) }, { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: 'entityType', useValue: 'Publication' }, { provide: SubmissionObjectService, useValue: { getHrefByID: () => of('testUrl'), findById: () => createSuccessfulRemoteDataObject$(new WorkspaceItem()) } }, ChangeDetectorRef, SubmissionSectionFormComponent, @@ -270,6 +271,7 @@ describe('SubmissionSectionFormComponent test suite', () => { formConfigService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(testFormConfiguration)); sectionsServiceStub.getSectionData.and.returnValue(of(sectionData)); sectionsServiceStub.getSectionServerErrors.and.returnValue(of([])); + submissionServiceStub.getSubmissionSecurityConfiguration.and.returnValue(of(sectionData)); sectionsServiceStub.isSectionReadOnly.and.returnValue(of(false)); spyOn(comp, 'initForm'); diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 06e56535e76..a8ae44358e0 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -21,6 +21,7 @@ import { getFirstSucceededRemoteData, getRemoteDataPayload, } from '@dspace/core/shared/operators'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; import { SubmissionSectionError } from '@dspace/core/submission/models/submission-section-error.model'; import { SubmissionSectionObject } from '@dspace/core/submission/models/submission-section-object.model'; @@ -44,7 +45,9 @@ import findIndex from 'lodash/findIndex'; import isEqual from 'lodash/isEqual'; import { combineLatest as observableCombineLatest, + interval, Observable, + race, Subscription, } from 'rxjs'; import { @@ -52,6 +55,7 @@ import { filter, find, map, + mapTo, mergeMap, take, tap, @@ -150,6 +154,8 @@ export class SubmissionSectionFormComponent extends SectionModelComponent implem */ protected subs: Subscription[] = []; + protected metadataSecurityConfiguration: MetadataSecurityConfiguration; + protected submissionObject: SubmissionObject; /** @@ -178,6 +184,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent implem * @param {ObjectCacheService} objectCache * @param {RequestService} requestService * @param {string} injectedCollectionId + * @param {string} entityType * @param {SectionDataObject} injectedSectionData * @param {string} injectedSubmissionId */ @@ -194,6 +201,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent implem protected objectCache: ObjectCacheService, protected requestService: RequestService, @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('entityType') public entityType: string, @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, @Inject('submissionIdProvider') public injectedSubmissionId: string) { super(injectedCollectionId, injectedSectionData, injectedSubmissionId); @@ -209,17 +217,28 @@ export class SubmissionSectionFormComponent extends SectionModelComponent implem this.formConfigService.findByHref(this.sectionData.config).pipe( map((configData: RemoteData) => configData.payload), tap((config: SubmissionFormsModel) => this.formConfig = config), - mergeMap(() => - observableCombineLatest([ - this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType), - this.submissionObjectService.findById(this.submissionId, true, false, followLink('item')).pipe( + mergeMap(() => { + const findById$ = this.submissionObjectService.findById(this.submissionId, false, true, followLink('item')).pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ); + const findByIdCached$ = interval(200).pipe( + mapTo(this.submissionObjectService.findById(this.submissionId, true, true, followLink('item')).pipe( getFirstSucceededRemoteData(), - getRemoteDataPayload()), + getRemoteDataPayload(), + )), + ); + return observableCombineLatest([ + this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType), + race([findById$, findByIdCached$]), + this.submissionService.getSubmissionSecurityConfiguration(this.submissionId).pipe(take(1)), this.sectionService.isSectionReadOnly(this.submissionId, this.sectionData.id, this.submissionService.getSubmissionScope()), - ])), + ]); + }), take(1)) - .subscribe(([sectionData, submissionObject, isSectionReadOnly]: [WorkspaceitemSectionFormObject, SubmissionObject, boolean]) => { + .subscribe(([sectionData, submissionObject, metadataSecurity, isSectionReadOnly]: [WorkspaceitemSectionFormObject, SubmissionObject, MetadataSecurityConfiguration, boolean]) => { if (isUndefined(this.formModel)) { + this.metadataSecurityConfiguration = metadataSecurity; // this.sectionData.errorsToShow = []; this.submissionObject = submissionObject; this.isSectionReadonly = isSectionReadOnly; @@ -339,6 +358,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent implem this.isSectionReadonly, null, false, + this.metadataSecurityConfiguration, ); const sectionMetadata = this.sectionService.computeSectionConfiguredMetadata(this.formConfig); this.sectionService.updateSectionData(this.submissionId, this.sectionData.id, sectionData, errorsToShow, serverValidationErrors, sectionMetadata); diff --git a/src/app/submission/selectors.ts b/src/app/submission/selectors.ts index 6ea60326c2d..8653851277e 100644 --- a/src/app/submission/selectors.ts +++ b/src/app/submission/selectors.ts @@ -2,6 +2,7 @@ import { keySelector, subStateSelector, } from '@dspace/core/ngrx/selectors-utils'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { SubmissionSectionObject } from '@dspace/core/submission/models/submission-section-object.model'; import { MemoizedSelector } from '@ngrx/store'; @@ -51,3 +52,7 @@ export function submissionSectionServerErrorsFromIdSelector(submissionId: string return subStateSelector(submissionIdSelector, 'serverValidationErrors'); } +export function securityConfigurationObjectFromIdSelector(submissionId: string): MemoizedSelector { + const submissionIdSelector = submissionObjectFromIdSelector(submissionId); + return subStateSelector(submissionIdSelector, 'metadataSecurityConfiguration'); +} diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index d8f693524bf..87d311dddfc 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -12,6 +12,7 @@ import { HttpOptions } from '@dspace/core/dspace-rest/dspace-rest.service'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { RouteService } from '@dspace/core/services/route.service'; import { Item } from '@dspace/core/shared/item.model'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { SectionScope } from '@dspace/core/submission/models/section-visibility.model'; import { SubmissionError } from '@dspace/core/submission/models/submission-error.model'; import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; @@ -75,7 +76,10 @@ import { SubmissionSectionEntry, } from './objects/submission-objects.reducer'; import { SectionDataObject } from './sections/models/section-data.model'; -import { submissionObjectFromIdSelector } from './selectors'; +import { + securityConfigurationObjectFromIdSelector, + submissionObjectFromIdSelector, +} from './selectors'; import { submissionSelector, SubmissionState, @@ -184,6 +188,30 @@ export class SubmissionService { catchError(() => of({} as SubmissionObject))); } + /** + * Perform a REST call to create a new workspaceitem for a specified collection and return response + * + * @param collectionId + * The collection id + * @return Observable + * observable of SubmissionObject + */ + createSubmissionForCollection(collectionId: string): Observable { + const paramsObj = Object.create({}); + + if (isNotEmpty(collectionId)) { + paramsObj.collection = collectionId; + } + + const params = new HttpParams({ fromObject: paramsObj }); + const options: HttpOptions = Object.create({}); + options.params = params; + + return this.restService.postToEndpoint(this.workspaceLinkPath, {}, null, options).pipe( + map((workspaceitem: SubmissionObject[]) => workspaceitem[0] as SubmissionObject), + catchError(() => of({} as SubmissionObject))); + } + /** * Perform a REST call to deposit a workspaceitem and return response * @@ -202,6 +230,30 @@ export class SubmissionService { return this.restService.postToEndpoint(this.workspaceLinkPath, selfUrl, null, options, collectionId) as Observable; } + /** + * Perform a REST call to create a new workspaceitem by item and return response + * + * @return Observable + * observable of SubmissionObject + */ + createSubmissionByItem(itemId: string, relationshipName?: string): Observable { + const paramsObj = Object.create({}); + + if (isNotEmpty(itemId)) { + paramsObj.item = itemId; + } + if (isNotEmpty(relationshipName)) { + paramsObj.relationship = relationshipName; + } + + const params = new HttpParams({ fromObject: paramsObj }); + const options: HttpOptions = Object.create({}); + options.params = params; + + return this.restService.postToEndpoint(this.workspaceLinkPath, {}, null, options).pipe( + map((workspaceitem: SubmissionObject[]) => workspaceitem[0] as SubmissionObject)); + } + /** * Perform a REST call to deposit a workspaceitem and return response * @@ -255,8 +307,9 @@ export class SubmissionService { submissionDefinition: SubmissionDefinitionsModel, sections: WorkspaceitemSectionsObject, item: Item, - errors: SubmissionError) { - this.store.dispatch(new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, sections, item, errors)); + errors: SubmissionError, + metadataSecurityConfiguration?: MetadataSecurityConfiguration) { + this.store.dispatch(new InitSubmissionFormAction(collectionId, submissionId, selfUrl, submissionDefinition, sections, item, errors, metadataSecurityConfiguration)); } /** @@ -343,6 +396,19 @@ export class SubmissionService { filter((submission: SubmissionObjectEntry) => isNotUndefined(submission))); } + /** + * Return the [MetadataSecurityConfiguration] for the specified submission + * + * @param submissionId + * The submission id + * @return Observable + * observable of MetadataSecurityConfiguration + */ + getSubmissionSecurityConfiguration(submissionId: string): Observable { + return this.store.select(securityConfigurationObjectFromIdSelector(submissionId)).pipe( + filter((securityConfiguration: MetadataSecurityConfiguration) => isNotUndefined(securityConfiguration))); + } + /** * Return a list of the active [SectionDataObject] belonging to the specified submission * @@ -643,8 +709,9 @@ export class SubmissionService { submissionDefinition: SubmissionDefinitionsModel, sections: WorkspaceitemSectionsObject, item: Item, + metadataSecurityConfiguration: MetadataSecurityConfiguration = null, ) { - this.store.dispatch(new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, sections, submissionDefinition, item)); + this.store.dispatch(new ResetSubmissionFormAction(collectionId, submissionId, selfUrl, sections, submissionDefinition, item, metadataSecurityConfiguration)); } /** diff --git a/src/app/submission/utils/submission.mock.ts b/src/app/submission/utils/submission.mock.ts index cb8821dab3c..ff7d297b04f 100644 --- a/src/app/submission/utils/submission.mock.ts +++ b/src/app/submission/utils/submission.mock.ts @@ -3,6 +3,8 @@ import { buildPaginatedList } from '@dspace/core/data/paginated-list.model'; import { Group } from '@dspace/core/eperson/models/group.model'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; import { PageInfo } from '@dspace/core/shared/page-info.model'; +import { METADATA_SECURITY_TYPE } from '@dspace/core/submission/models/metadata-security-config.resource-type'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { SubmissionObjectState } from '../objects/submission-objects.reducer'; @@ -324,6 +326,18 @@ export const mockSubmissionRestResponse = [ }, ]; +export const mockSecurityConfig: MetadataSecurityConfiguration = { + 'uuid' : 'Person', + 'metadataSecurityDefault' : [ 0, 1, 2 ], + 'metadataCustomSecurity' : { 'person.birthDate': [ 0, 1 ] }, + 'type' : METADATA_SECURITY_TYPE, + '_links' : { + 'self' : { + 'href' : 'http://localhost:8080/server/api/core/securitysettings/Person', + }, + }, +}; + export const mockSubmissionObject = { collection: { handle: '10673/2', @@ -581,6 +595,7 @@ export const mockSubmissionObject = { ], }, ], + metadataSecurityConfiguration: mockSecurityConfig, type: 'workspaceitem', _links: { collection: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection' }, @@ -1834,4 +1849,3 @@ export const mockAccessesFormData = { }, ], }; - diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index e5c564ac12d..b66094572c3 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1379,6 +1379,10 @@ "collection.form.errors.title.required": "Please enter a collection name", + "collection.form.errors.submissionDefinition.required": "Please choose a submission definition for this collection", + + "collection.form.errors.entityType.required": "Please choose an entity type for this collection", + "collection.form.license": "License", "collection.form.provenance": "Provenance", diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 297e2a3d61d..916958e308b 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -29,6 +29,7 @@ import { MarkdownConfig } from './markdown-config.interface'; import { MatomoConfig } from './matomo-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { MetadataLinkViewPopoverDataConfig } from './metadata-link-view-popoverdata-config.interface'; +import { MetadataSecurityConfig } from './metadata-security-config'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { QualityAssuranceConfig } from './quality-assurance.config'; import { FollowAuthorityMetadata } from './search-follow-metadata.interface'; @@ -81,6 +82,7 @@ interface AppConfig extends Config { followAuthorityMetadata: FollowAuthorityMetadata[]; followAuthorityMaxItemLimit: number; followAuthorityMetadataValuesLimit: number; + security: MetadataSecurityConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 983e2f59040..3111604954f 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -29,6 +29,7 @@ import { MarkdownConfig } from './markdown-config.interface'; import { MatomoConfig } from './matomo-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { MetadataLinkViewPopoverDataConfig } from './metadata-link-view-popoverdata-config.interface'; +import { MetadataSecurityConfig } from './metadata-security-config'; import { INotificationBoardOptions, NotificationAnimationsType, @@ -778,4 +779,25 @@ export class DefaultAppConfig implements AppConfig { }, ]; + + security: MetadataSecurityConfig = { + levels: [ + { + value: 0, + icon: 'fa fa-globe', + color: 'green', + }, + { + value: 1, + icon: 'fa fa-key', + color: 'orange', + }, + { + value: 2, + icon: 'fa fa-lock', + color: 'red', + }, + ], + }; + } diff --git a/src/config/metadata-security-config.ts b/src/config/metadata-security-config.ts new file mode 100644 index 00000000000..fe12905c91b --- /dev/null +++ b/src/config/metadata-security-config.ts @@ -0,0 +1,11 @@ +import { Config } from './config.interface'; + +export interface MetadataSecurityConfig extends Config { + levels: LevelSecurityConfig[]; +} + +export interface LevelSecurityConfig extends Config { + value: number; + icon: string; + color: string; +} diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 1d47e9afae5..289d3777a12 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -572,4 +572,23 @@ export const environment: BuildConfig = { metadata: ['dc.contributor.author'], }, ], + + security: { + levels: [ + { + value: 0, + icon: 'fa fa-globe', + color: 'green', + }, + { + value: 1, + icon: 'fa fa-key', + color: 'orange', + }, + { + value: 2, + icon: 'fa fa-lock', + color: 'red', + }], + }, }; From 87cfa6e233dc5747a03512450ec0bac75c4488ad Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Thu, 19 Feb 2026 18:10:37 +0100 Subject: [PATCH 04/12] [DURACOM-453] fix observable issue, add full projection --- src/app/shared/menu/providers/edit-item-details.menu.ts | 1 - src/app/submission/edit/submission-edit.component.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/shared/menu/providers/edit-item-details.menu.ts b/src/app/shared/menu/providers/edit-item-details.menu.ts index 422026e8f96..4938b198d39 100644 --- a/src/app/shared/menu/providers/edit-item-details.menu.ts +++ b/src/app/shared/menu/providers/edit-item-details.menu.ts @@ -41,7 +41,6 @@ export class EditItemDetailsMenuProvider extends DSpaceObjectPageMenuProvider { return this.editItemService.searchEditModesById(dso.id).pipe( getAllSucceededRemoteDataPayload(), getPaginatedListPayload(), - startWith([]), map((editModes: EditItemMode[]) => { return editModes.map(editMode => { return { diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 0af96a7ae65..83c637c1b8b 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -185,7 +185,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.canDeactivate = res; }), this.route.paramMap.pipe( - switchMap((params: ParamMap) => this.submissionService.retrieveSubmission(params.get('id'), ['allLanguages']).pipe( + switchMap((params: ParamMap) => this.submissionService.retrieveSubmission(params.get('id'), ['full','allLanguages']).pipe( // NOTE new submission is retrieved on the browser side only, so get null on server side rendering filter((submissionObjectRD: RemoteData) => isNotNull(submissionObjectRD)), mergeMap((submissionObjectRD: RemoteData) => combineLatest([ From ab0885e1aa93e4ae7c6ebbf1bfeba8d9f3dbabec Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 20 Feb 2026 18:30:03 +0100 Subject: [PATCH 05/12] [DURACOM-453] add shared submission config and labels --- .../my-dspace-configuration-value-type.ts | 1 + .../my-dspace-configuration.service.spec.ts | 2 ++ .../my-dspace-configuration.service.ts | 1 + .../providers/edit-item-details.menu.spec.ts | 5 ++--- .../menu/providers/edit-item-details.menu.ts | 1 - src/assets/i18n/en.json5 | 16 ++++++++++++++++ 6 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/my-dspace-page/my-dspace-configuration-value-type.ts b/src/app/my-dspace-page/my-dspace-configuration-value-type.ts index c8484d97a06..7cc4dafcdde 100644 --- a/src/app/my-dspace-page/my-dspace-configuration-value-type.ts +++ b/src/app/my-dspace-page/my-dspace-configuration-value-type.ts @@ -1,5 +1,6 @@ export enum MyDSpaceConfigurationValueType { Workspace = 'workspace', SupervisedItems = 'supervisedWorkspace', + OtherWorkspace = 'otherworkspace', Workflow = 'workflow' } diff --git a/src/app/my-dspace-page/my-dspace-configuration.service.spec.ts b/src/app/my-dspace-page/my-dspace-configuration.service.spec.ts index a62c2c64a97..3b0429594ec 100644 --- a/src/app/my-dspace-page/my-dspace-configuration.service.spec.ts +++ b/src/app/my-dspace-page/my-dspace-configuration.service.spec.ts @@ -192,6 +192,7 @@ describe('MyDSpaceConfigurationService', () => { expect(list$).toBeObservable(cold('(b|)', { b: [ MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.OtherWorkspace, ], })); }); @@ -236,6 +237,7 @@ describe('MyDSpaceConfigurationService', () => { expect(list$).toBeObservable(cold('(b|)', { b: [ MyDSpaceConfigurationValueType.Workspace, + MyDSpaceConfigurationValueType.OtherWorkspace, MyDSpaceConfigurationValueType.SupervisedItems, MyDSpaceConfigurationValueType.Workflow, ], diff --git a/src/app/my-dspace-page/my-dspace-configuration.service.ts b/src/app/my-dspace-page/my-dspace-configuration.service.ts index 7c4feb1b4db..751cc39b1d2 100644 --- a/src/app/my-dspace-page/my-dspace-configuration.service.ts +++ b/src/app/my-dspace-page/my-dspace-configuration.service.ts @@ -123,6 +123,7 @@ export class MyDSpaceConfigurationService extends SearchConfigurationService { const availableConf: MyDSpaceConfigurationValueType[] = []; if (isSubmitter) { availableConf.push(MyDSpaceConfigurationValueType.Workspace); + availableConf.push(MyDSpaceConfigurationValueType.OtherWorkspace); } if (isController || isAdmin) { availableConf.push(MyDSpaceConfigurationValueType.SupervisedItems); diff --git a/src/app/shared/menu/providers/edit-item-details.menu.spec.ts b/src/app/shared/menu/providers/edit-item-details.menu.spec.ts index a814f843d35..a07a1410f0e 100644 --- a/src/app/shared/menu/providers/edit-item-details.menu.spec.ts +++ b/src/app/shared/menu/providers/edit-item-details.menu.spec.ts @@ -79,9 +79,8 @@ describe('EditItemDetailsMenuProvider', () => { const result = provider.getSectionsForContext(mockDSO); - const expected = cold('(ab|)', { - a: [], - b: expectedSections, + const expected = cold('(a|)', { + a: expectedSections, }); expect(result).toBeObservable(expected); diff --git a/src/app/shared/menu/providers/edit-item-details.menu.ts b/src/app/shared/menu/providers/edit-item-details.menu.ts index 4938b198d39..73ed9b2929d 100644 --- a/src/app/shared/menu/providers/edit-item-details.menu.ts +++ b/src/app/shared/menu/providers/edit-item-details.menu.ts @@ -18,7 +18,6 @@ import { map, Observable, } from 'rxjs'; -import { startWith } from 'rxjs/operators'; import { getEditItemPageRoute } from '../../../app-routing-paths'; import { MenuItemType } from '../menu-item-type.model'; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b66094572c3..ea6025c35c7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1389,12 +1389,24 @@ "collection.form.rights": "Copyright text (HTML)", + "collection.form.sharedWorkspace": "Shared workspace", + "collection.form.tableofcontents": "News (HTML)", "collection.form.title": "Name", "collection.form.entityType": "Entity Type", + "collection.form.errors.entityType.required": "Please choose an entity type for this collection", + + "collection.form.errors.submissionDefinition.required": "Please choose a submission definition for this collection", + + "collection.form.submissionDefinition": "Submission definition", + + "collection.form.correctionSubmissionDefinition": "Submission definition for correction request", + + + "collection.listelement.badge": "Collection", "collection.logo": "Collection logo", @@ -3797,6 +3809,8 @@ "mydspace.show.workspace": "Your submissions", + "mydspace.show.otherworkspace": "Other Workspace Submissions", + "mydspace.show.supervisedWorkspace": "Supervised items", "mydspace.status": "My DSpace status:", @@ -6112,6 +6126,8 @@ "workflow.search.results.head": "Workflow tasks", + "otherworkspace.search.results.head": "Workspace submissions", + "supervision.search.results.head": "Workflow and Workspace tasks", "workflow-item.edit.breadcrumbs": "Edit workflowitem", From c35253427784a439535451df7de0ef6665e044f9 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Tue, 10 Mar 2026 17:45:45 +0100 Subject: [PATCH 06/12] [DURACOM-453] port metadata security update, init and administrate --- .../dso-edit-metadata-form.ts | 29 +++++++++ .../dso-edit-metadata-headers.component.html | 1 + ...so-edit-metadata-headers.component.spec.ts | 4 +- .../dso-edit-metadata-cells.scss | 9 +++ ...edit-metadata-value-headers.component.html | 1 + .../dso-edit-metadata-value.component.ts | 3 +- .../dso-edit-metadata.component.html | 3 +- .../dso-edit-metadata.component.ts | 2 +- ...amic-form-control-container.component.html | 4 +- .../objects/submission-objects.effects.ts | 1 + .../submission-objects.reducer.spec.ts | 20 ++++++- .../objects/submission-objects.reducer.ts | 9 ++- .../form/section-form-operations.service.ts | 60 ++++++++++++++++++- src/assets/i18n/en.json5 | 2 + src/styles/_custom_variables.scss | 2 + 15 files changed, 136 insertions(+), 14 deletions(-) diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts index 5b25fd083e3..ff60cefef9b 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts @@ -89,6 +89,8 @@ export class DsoEditMetadataValue { if (this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language || this.originalValue.authority !== this.newValue.authority || this.originalValue.confidence !== this.newValue.confidence) { this.change = DsoEditMetadataChangeType.UPDATE; + } else if (!hasValue(this.originalValue.authority) && hasValue(this.newValue.authority)) { + this.change = DsoEditMetadataChangeType.ADD; } else { this.change = undefined; } @@ -362,8 +364,10 @@ export class DsoEditMetadataForm { }); }); // Reset the order of values within their fields to match their place property + // And reinstate the security level values this.fieldKeys.forEach((field: string) => { this.setValuesForFieldSorted(field, this.fields[field]); + this.reinstateSecurityLevel(field); }); this.reinstatableNewValues = {}; } @@ -399,6 +403,19 @@ export class DsoEditMetadataForm { this.fields[mdField] = values.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place); } + /** + * Set the change property of each value within a metadata field, + * in case the security level has been changed and we are trying to reinstate the changes + * @param mdField + */ + private reinstateSecurityLevel(mdField: string){ + this.fields[mdField].forEach((value: DsoEditMetadataValue) => { + if (hasValue(value.newValue.securityLevel) && value.newValue.securityLevel !== value.originalValue.securityLevel) { + value.change = DsoEditMetadataChangeType.UPDATE; + } + }); + } + /** * Get the json PATCH operations for the current changes within this form * For each metadata field, it'll return operations in the following order: replace, remove (from last to first place), add and move @@ -423,6 +440,17 @@ export class DsoEditMetadataForm { language: value.newValue.language, authority: value.newValue.authority, confidence: value.newValue.confidence, + securityLevel: value.originalValue.securityLevel, + })); + } + // "replace" the security level value + if (value.originalValue.securityLevel !== value.newValue.securityLevel) { + replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, { + securityLevel: value.newValue.securityLevel, + value: value.newValue.value, + language: value.newValue.language, + authority: value.newValue.authority, + confidence: value.newValue.confidence, })); } } else if (value.change === DsoEditMetadataChangeType.REMOVE) { @@ -433,6 +461,7 @@ export class DsoEditMetadataForm { language: value.newValue.language, authority: value.newValue.authority, confidence: value.newValue.confidence, + securityLevel: value.newValue.securityLevel, })); } else { console.warn('Illegal metadata change state detected for', value); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html index 15dc97f7da4..3b4120f3be1 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.html @@ -5,6 +5,7 @@
{{ dsoType + '.edit.metadata.headers.value' | translate }}
{{ dsoType + '.edit.metadata.headers.language' | translate }}
{{ dsoType + '.edit.metadata.headers.authority' | translate }}
+
{{'item.edit.metadata.headers.security'| translate}}
{{ dsoType + '.edit.metadata.headers.edit' | translate }}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts index e9358c9e186..4046269d358 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component.spec.ts @@ -29,7 +29,7 @@ describe('DsoEditMetadataHeadersComponent', () => { fixture.detectChanges(); }); - it('should display four headers', () => { - expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(4); + it('should display five headers', () => { + expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(5); }); }); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss index ce9ed9c481d..eaedebc7693 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-shared/dso-edit-metadata-cells.scss @@ -17,9 +17,18 @@ max-width: var(--ds-dso-edit-authority-width); } +.ds-security-cell { + min-width: var(--ds-dso-edit-security-width); + max-width: var(--ds-dso-edit-security-width); +} .ds-edit-cell { min-width: var(--ds-dso-edit-actions-width); + max-width: var(--ds-dso-edit-actions-width); +} + +.ds-value-cell { + max-width: var(--ds-dso-edit-value-max-width); } .ds-value-row { diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html index 2c3fb546003..d3c5c65657f 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component.html @@ -1,5 +1,6 @@
{{ dsoType + '.edit.metadata.headers.value' | translate }}
{{ dsoType + '.edit.metadata.headers.language' | translate }}
+
{{ dsoType + '.edit.metadata.headers.authority' | translate }}
{{ dsoType + '.edit.metadata.headers.edit' | translate }}
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 65e00fbde5e..8c52cdcacb8 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -62,6 +62,7 @@ import { } from 'rxjs'; import { distinctUntilChanged, + filter, map, shareReplay, switchMap, @@ -274,7 +275,7 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges, OnDestr this.sub = combineLatest([ this._mdField$, - this._metadataSecurityConfiguration$, + this._metadataSecurityConfiguration$.pipe(filter(config => !!config)), ]).subscribe(([mdField, metadataSecurityConfig]) => this.initSecurityLevel(mdField, metadataSecurityConfig)); this.canShowMetadataSecurity$ = diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index d2a59061a36..24228a4786f 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -1,4 +1,4 @@ -@if (form) { +@if (isFormInitialized$ | async) {