diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index da1ee400c4c..6190ed263a3 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, @@ -280,6 +281,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/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/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index bc049aa6b5b..d85b61911d7 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -10,13 +10,15 @@ 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'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { Collection } from '@dspace/core/shared/collection.model'; import { ItemType } from '@dspace/core/shared/item-relationships/item-type.model'; -import { NONE_ENTITY_TYPE } from '@dspace/core/shared/item-relationships/item-type.resource-type'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; import { getFirstSucceededRemoteListPayload } from '@dspace/core/shared/operators'; import { @@ -25,6 +27,7 @@ import { } from '@dspace/shared/utils/empty.util'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { + DynamicCheckboxModel, DynamicFormControlModel, DynamicFormOptionConfig, DynamicFormService, @@ -34,7 +37,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'; @@ -44,6 +52,8 @@ import { VarDirective } from '../../shared/utils/var.directive'; import { collectionFormEntityTypeSelectionConfig, collectionFormModels, + collectionFormSharedWorkspaceCheckboxConfig, + collectionFormSubmissionDefinitionSelectionConfig, } from './collection-form.models'; /** @@ -79,6 +89,14 @@ 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); + + sharedWorkspaceChekbox: DynamicCheckboxModel = new DynamicCheckboxModel(collectionFormSharedWorkspaceCheckboxConfig); + /** * The dynamic form fields used for creating/editing a collection * @type {DynamicFormControlModel[]} @@ -94,6 +112,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 +136,65 @@ export class CollectionFormComponent extends ComColFormComponent imp initializeForm() { let currentRelationshipValue: MetadataValue[]; + let currentDefinitionValue: MetadataValue[]; + let currentSharedWorkspaceValue: MetadataValue[]; if (this.dso && this.dso.metadata) { currentRelationshipValue = this.dso.metadata['dspace.entity.type']; + currentDefinitionValue = this.dso.metadata['dspace.submission.definition']; + currentSharedWorkspaceValue = this.dso.metadata['dspace.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([])), + ); - this.formModel = entityTypes.length === 0 ? collectionFormModels : [...collectionFormModels, this.entityTypeSelection]; + // retrieve all entity types and submission definitions to populate the dropdowns selection + combineLatest([entities$, definitions$]) + .subscribe(([entityTypes, definitions]: [ItemType[], SubmissionDefinitionModel[]]) => { - super.ngOnInit(); - this.chd.detectChanges(); - }); + const sortedEntityTypes = entityTypes + .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; + } + }); + + definitions.forEach((definition: SubmissionDefinitionModel, index: number) => { + this.submissionDefinitionSelection.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); + } + }); + + this.formModel = entityTypes.length === 0 ? + [...collectionFormModels, this.submissionDefinitionSelection, this.sharedWorkspaceChekbox] : + [...collectionFormModels, this.entityTypeSelection, this.submissionDefinitionSelection, 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..5f85755e591 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, @@ -11,6 +12,25 @@ export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig = { + id: 'submissionDefinition', + name: 'dspace.submission.definition', + disabled: false, + errorMessages: { + required: 'collection.form.errors.submissionDefinition.required', + }, +}; + + +export const collectionFormSharedWorkspaceCheckboxConfig: DynamicCheckboxModelConfig = { + id: 'sharedWorkspace', + name: 'dspace.workspace.shared', + disabled: false, }; /** diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 3a8a78196e1..93857ad0783 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -339,6 +339,7 @@ export class RemoteDataBuildService { response.errorMessage, payload, response.statusCode, + response.errors, ); }), ); 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 77f39e080dd..ea7d3b51996 100644 --- a/src/app/core/data-services-map.ts +++ b/src/app/core/data-services-map.ts @@ -58,6 +58,8 @@ 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 { 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 { @@ -138,4 +140,6 @@ 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)], + [METADATA_SECURITY_TYPE.value, () => import('./submission/metadatasecurityconfig-data.service').then(m => m.MetadataSecurityConfigurationService)], ]); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts index 3036eb72e28..d70779eea9e 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts @@ -25,6 +25,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Deleted title', place: 0, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -48,12 +49,13 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Added title', place: 0, + securityLevel: 1, }), changeType: FieldChangeType.ADD, }, }); expected = [ - { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined }] }, + { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined, securityLevel: 1 }] }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -71,12 +73,13 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Changed title', place: 0, + securityLevel: 1, }), changeType: FieldChangeType.UPDATE, }, }); expected = [ - { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } }, + { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined, securityLevel: 1 } }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -94,6 +97,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'First deleted title', place: 0, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -102,6 +106,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Second deleted title', place: 1, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -110,6 +115,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Third deleted title', place: 2, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -135,6 +141,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Third deleted title', place: 2, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -143,6 +150,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Second deleted title', place: 1, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -151,6 +159,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'First deleted title', place: 0, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -176,6 +185,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Second deleted title', place: 1, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -184,6 +194,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Third deleted title', place: 2, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -192,6 +203,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'First deleted title', place: 0, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -217,6 +229,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Second deleted title', place: 1, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, @@ -225,6 +238,7 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'Third changed title', place: 2, + securityLevel: 1, }), changeType: FieldChangeType.UPDATE, }, @@ -233,13 +247,14 @@ describe('MetadataPatchOperationService', () => { key: 'dc.title', value: 'First deleted title', place: 0, + securityLevel: 1, }), changeType: FieldChangeType.REMOVE, }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/1' }, - { op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } }, + { op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined ,securityLevel: 1 } }, { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts index 468ac2b2050..ea605dff92d 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts @@ -77,6 +77,7 @@ export class MetadataPatchOperationService implements PatchOperationService { const val = { value: metadatum.value, language: metadatum.language, + securityLevel: metadatum.securityLevel, }; let operation: MetadataPatchOperation; 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/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts index 707f8be12c7..6eddc8d8067 100644 --- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -1,4 +1,6 @@ import { Injectable } from '@angular/core'; +import { ConfidenceType } from '@dspace/core/shared/confidence-type'; +import { Metadata } from '@dspace/core/shared/metadata.utils'; import { dateToISOFormat, dateToString, @@ -52,7 +54,7 @@ export class JsonPatchOperationsBuilder { new NewPatchAddOperationAction( path.rootElement, path.subRootElement, - path.path, this.prepareValue(value, plain, first, languages))); + path.path, this.prepareValue(value, plain, first, null, languages))); } /** @@ -64,9 +66,10 @@ export class JsonPatchOperationsBuilder { * the value to update the referenced path * @param plain * a boolean representing if the value to be added is a plain text value + * @param securityLevel * @param language */ - replace(path: JsonPatchOperationPathObject, value, plain = false, language = null) { + replace(path: JsonPatchOperationPathObject, value, plain = false, securityLevel = null, language = null) { if (hasNoValue(value) || (typeof value === 'object' && hasNoValue(value.value))) { this.remove(path); } else { @@ -75,7 +78,7 @@ export class JsonPatchOperationsBuilder { path.rootElement, path.subRootElement, path.path, - this.prepareValue(value, plain, false, language))); + this.prepareValue(value, plain, false, securityLevel, language))); } } @@ -126,7 +129,7 @@ export class JsonPatchOperationsBuilder { path.path)); } - protected prepareValue(value: any, plain: boolean, first: boolean, languages: string[] = null) { + protected prepareValue(value: any, plain: boolean, first: boolean, securityLevel = null, languages: string[] = null) { let operationValue: any = null; if (hasValue(value)) { if (plain) { @@ -134,31 +137,59 @@ export class JsonPatchOperationsBuilder { } else { if (Array.isArray(value)) { operationValue = []; - value.forEach((entry) => { + value.forEach((entry, index) => { if ((typeof entry === 'object')) { - operationValue.push(this.prepareObjectValue(entry)); + if (isNotEmpty(securityLevel)) { + operationValue.push(this.prepareObjectValue(entry, securityLevel)); + } else { + operationValue.push(this.prepareObjectValue(entry)); + } } else { - operationValue.push(new FormFieldMetadataValueObject(entry)); + operationValue.push(new FormFieldMetadataValueObject(entry, languages ? languages[index] : null, securityLevel)); } }); } else if (typeof value === 'object') { - operationValue = this.prepareObjectValue(value); + if (isNotEmpty(securityLevel)) { + operationValue = this.prepareObjectValue(value, securityLevel); + } else { + operationValue = this.prepareObjectValue(value); + } } else { - operationValue = new FormFieldMetadataValueObject(value); + // add the possibility to add security level when value is string + // in this case security level is set on metadata value + if (isNotEmpty(securityLevel)) { + operationValue = new FormFieldMetadataValueObject(value, null, securityLevel); + } else { + operationValue = new FormFieldMetadataValueObject(value, null); + } + } } } return (first && !Array.isArray(operationValue)) ? [operationValue] : operationValue; } - protected prepareObjectValue(value: any) { + protected prepareObjectValue(value: any, securityLevel = null) { let operationValue = Object.create({}); if (isEmpty(value) || value instanceof FormFieldMetadataValueObject) { - operationValue = value; + if (isNotEmpty(securityLevel)) { + operationValue = { ...value, securityLevel: securityLevel }; + } else { + operationValue = value; + } + //Update confidence if was added once the field was already created, value is set only in constructor of FormFieldMetadataValueObject + if (Metadata.hasValidAuthority(operationValue.authority) && (isEmpty(operationValue.confidence) || operationValue.confidence === -1)) { + operationValue.confidence = ConfidenceType.CF_ACCEPTED; + } + } else if (value instanceof Date) { - operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value)); + if (securityLevel != null) { + operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value), null, securityLevel); + } else { + operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value)); + } } else if (value instanceof VocabularyEntry) { - operationValue = this.prepareAuthorityValue(value); + operationValue = this.prepareAuthorityValue(value, securityLevel); } else if (value instanceof FormFieldLanguageValueObject) { operationValue = new FormFieldMetadataValueObject(value.value, value.language); } else if (value.hasOwnProperty('authority')) { @@ -180,12 +211,12 @@ export class JsonPatchOperationsBuilder { return operationValue; } - protected prepareAuthorityValue(value: any): FormFieldMetadataValueObject { + protected prepareAuthorityValue(value: any, securityLevel = null): FormFieldMetadataValueObject { let operationValue: FormFieldMetadataValueObject; if (isNotEmpty(value.authority)) { - operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority); + operationValue = new FormFieldMetadataValueObject(value.value, value.language, securityLevel, value.authority); } else { - operationValue = new FormFieldMetadataValueObject(value.value, value.language); + operationValue = new FormFieldMetadataValueObject(value.value, value.language, securityLevel); } return operationValue; } diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts index 8199c074b51..d4f685844a7 100644 --- a/src/app/core/json-patch/json-patch-operations.service.ts +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -112,7 +112,7 @@ export abstract class JsonPatchOperationsService) => { if (rd.hasFailed) { this.store.dispatch(new RollbacktPatchOperationsAction(resourceType, resourceId)); - throw new Error(rd.errorMessage); + throw rd as unknown as Error; } else if (hasValue(rd.payload) && isNotEmpty(rd.payload.dataDefinition)) { this.store.dispatch(new CommitPatchOperationsAction(resourceType, resourceId)); return rd.payload.dataDefinition; 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/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/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/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/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/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts index d4836f6dcf1..41d4fabe0d1 100644 --- a/src/app/core/submission/models/submission-object.model.ts +++ b/src/app/core/submission/models/submission-object.model.ts @@ -1,3 +1,4 @@ +import { PathableObjectError } from '@dspace/core/data/response-state.model'; import { autoserialize, deserialize, @@ -22,10 +23,7 @@ import { SUPERVISION_ORDER } from '../../supervision-order/models/supervision-or import { excludeFromEquals } from '../../utilities/equals.decorators'; import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; -export interface SubmissionObjectError { - message: string; - paths: string[]; -} +export type SubmissionObjectError = PathableObjectError; /** * An abstract model class for a SubmissionObject. 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-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index 035d263e460..a8c31df7d65 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,11 +57,13 @@ 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, obj.confidence, obj.otherInformation, + obj.source, ); } else if (Array.isArray(obj)) { result = []; @@ -144,7 +147,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/testing/submission-service.stub.ts b/src/app/core/testing/submission-service.stub.ts index d9d28bde0ee..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'); @@ -25,6 +26,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/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-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..832899b7512 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,14 @@ 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-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.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index e7c8f2bc450..d5e3540cdd7 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..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 @@ -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,23 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, EMPTY, Observable, + of, + Subscription, } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + 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 +95,7 @@ import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-v CdkDragHandle, DebounceDirective, DsoEditMetadataValueFieldLoaderComponent, + EditMetadataSecurityComponent, FormsModule, NgbTooltip, NgClass, @@ -79,7 +107,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 +121,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 +198,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 +228,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 +272,29 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { ngOnInit(): void { this.initVirtualProperties(); + + this.sub = combineLatest([ + this._mdField$, + this._metadataSecurityConfiguration$.pipe(filter(config => !!config)), + ]).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 +315,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 +363,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..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) { @@ -76,6 +79,7 @@ [form]="form" [dsoType]="dsoType" [saving$]="saving$" + [metadataSecurityConfiguration]="(securitySettings$ | async)" [draggingMdField$]="draggingMdField$" [mdField]="mdField" (valueSaved)="onValueSaved()"> 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..9fa90f230ca 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,14 +43,16 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, combineLatest as observableCombineLatest, Observable, of, Subscription, } from 'rxjs'; import { + catchError, map, - mergeMap, + switchMap, tap, } from 'rxjs/operators'; @@ -53,7 +61,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 +164,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 +194,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 +207,48 @@ 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; + }), + catchError(() => of(null)), + ); + } else { + return of(null); + } + } + /** * Resolve the data-service for the current DSpaceObject and retrieve its instance */ @@ -297,6 +356,7 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { this.loadingFieldValidation$.next(false); if (valid) { this.form.setMetadataField(this.newMdField); + this.setSecurityLevelForNewMdField(); this.onValueSaved(); } }); @@ -326,6 +386,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/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/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/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/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 95a48a7f05d..1cb1abb1185 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 @@ -17,7 +17,7 @@ } -
0)}">
@@ -48,7 +48,7 @@
@if (model.languageCodes && model.languageCodes.length > 0) { -
+