diff --git a/config/config.example.yml b/config/config.example.yml index 6fdfa94add5..0aca292f6fd 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -238,6 +238,12 @@ submission: style: text-muted icon: fa-circle-xmark + # Icons to be displayed next to an authority controlled value, to give indication of the source. + sourceIcons: + # Example of configuration for authority logo based on sources. + # The configured icon will be displayed next to the authority value in submission and on item page or search results. + - source: orcid + - path: assets/images/orcid.logo.icon.svg # Fallback language in which the UI will be rendered if the user's browser language is not an active language fallbackLanguage: en @@ -393,7 +399,8 @@ item: pageSize: 5 # Show the bitstream access status label on the item page showAccessStatuses: false - + # Enable authority based relations in item page + showAuthorithyRelations: false # Community Page Config community: # Default tab to be shown when browsing a Community. Valid values are: comcols, search, or browse_ @@ -641,3 +648,86 @@ geospatialMapViewer: accessibility: # The duration in days after which the accessibility settings cookie expires cookieExpirationDuration: 7 + +# Configuration for custom layout +layout: + # Configuration of icons and styles to be used for each authority controlled link + authorityRef: + - entityType: DEFAULT + entityStyle: + default: + icon: fa fa-user + style: text-info + - entityType: PERSON + entityStyle: + person: + icon: fa fa-user + style: text-success + default: + icon: fa fa-user + style: text-info + - entityType: ORGUNIT + entityStyle: + default: + icon: fa fa-university + style: text-success + - entityType: PROJECT + entityStyle: + default: + icon: fas fa-project-diagram + style: text-success + +# When the search results are retrieved, for each item type the metadata with a valid authority value are inspected. +# Referenced items will be fetched with a find all by id strategy to avoid individual rest requests +# to efficiently display the search results. +followAuthorityMetadata: + - type: Publication + metadata: dc.contributor.author + - type: Product + metadata: dc.contributor.author + +# The maximum number of item to process when following authority metadata values. +followAuthorityMaxItemLimit: 100 + +# The maximum number of metadata values to process for each metadata key +# when following authority metadata values. +followAuthorityMetadataValuesLimit: 5 + +# Configuration for customization of search results +searchResults: + # Metadata fields to be displayed in the search results under the standard ones + additionalMetadataFields: + - dc.contributor.author + - dc.date.issued + - dc.type + # Metadata fields to be displayed in the search results for the author section + authorMetadata: + - dc.contributor.author + - dc.creator + - dc.contributor.* + +# Configuration of metadata to be displayed in the item metadata link view popover +metadataLinkViewPopoverData: + # Metdadata list to be displayed for entities without a specific configuration + fallbackMetdataList: + - dc.description.abstract + - dc.description.note + # Configuration for each entity type + entityDataConfig: + - entityType: Person + # Descriptive metadata (popover body) + metadataList: + - person.affiliation.name + - person.email + # Title metadata (popover header) + titleMetadataList: + - person.givenName + - person.familyName +# Configuration for identifier subtypes, based on metadata like dc.identifier.ror where ror is the subtype. +# This is used to map the layout of the identifier in the popover and the icon displayed next to the metadata value. +identifierSubtypes: + - name: ror + icon: assets/images/ror.logo.icon.svg + iconPosition: IdentifierSubtypesIconPositionEnum.LEFT + link: https://ror.org + diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 3ccabfdb212..a769f67ae91 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -28,6 +28,7 @@ import { ACCESS_CONTROL_MODULE_PATH } from './access-control/access-control-rout import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; import { ADMIN_MODULE_PATH, + EDIT_ITEM_PATH, FORGOT_PASSWORD_PATH, HEALTH_PAGE_PATH, PROFILE_MODULE_PATH, @@ -279,6 +280,11 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [authenticatedGuard], }, + { + path: EDIT_ITEM_PATH, + loadChildren: () => import('./edit-item/edit-item-routes').then((m) => m.ROUTES), + canActivate: [endUserAgreementCurrentUserGuard], + }, { path: 'external-login/:token', loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES), diff --git a/src/app/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/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts index 05bd4e07f14..a2339dbacc4 100644 --- a/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts @@ -128,7 +128,7 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy { this.selectedItems = []; this.facetType = browseDefinition.facetType; this.vocabularyName = browseDefinition.vocabulary; - this.vocabularyOptions = { name: this.vocabularyName, closed: true }; + this.vocabularyOptions = { name: this.vocabularyName, metadata: null, scope: null, closed: true }; this.description = this.translate.instant(`browse.metadata.${this.vocabularyName}.tree.description`); })); this.subs.push(this.scope$.subscribe(() => { diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index bc049aa6b5b..6bd7e650440 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -10,6 +10,9 @@ import { } from '@angular/core'; import { AuthService } from '@dspace/core/auth/auth.service'; import { ObjectCacheService } from '@dspace/core/cache/object-cache.service'; +import { ConfigObject } from '@dspace/core/config/models/config.model'; +import { SubmissionDefinitionModel } from '@dspace/core/config/models/config-submission-definition.model'; +import { SubmissionDefinitionsConfigDataService } from '@dspace/core/config/submission-definitions-config-data.service'; import { CollectionDataService } from '@dspace/core/data/collection-data.service'; import { EntityTypeDataService } from '@dspace/core/data/entity-type-data.service'; import { RequestService } from '@dspace/core/data/request.service'; @@ -25,6 +28,7 @@ import { } from '@dspace/shared/utils/empty.util'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { + DynamicCheckboxModel, DynamicFormControlModel, DynamicFormOptionConfig, DynamicFormService, @@ -34,7 +38,12 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; +import { + catchError, + combineLatest, + Observable, + of, +} from 'rxjs'; import { ComColFormComponent } from '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component'; import { ComcolPageLogoComponent } from '../../shared/comcol/comcol-page-logo/comcol-page-logo.component'; @@ -42,8 +51,11 @@ import { FormComponent } from '../../shared/form/form.component'; import { UploaderComponent } from '../../shared/upload/uploader/uploader.component'; import { VarDirective } from '../../shared/utils/var.directive'; import { + collectionFormCorrectionSubmissionDefinitionSelectionConfig, collectionFormEntityTypeSelectionConfig, collectionFormModels, + collectionFormSharedWorkspaceCheckboxConfig, + collectionFormSubmissionDefinitionSelectionConfig, } from './collection-form.models'; /** @@ -79,6 +91,20 @@ export class CollectionFormComponent extends ComColFormComponent imp */ entityTypeSelection: DynamicSelectModel = new DynamicSelectModel(collectionFormEntityTypeSelectionConfig); + /** + * The dynamic form field used for submission definition selection + * @type {DynamicSelectModel} + */ + submissionDefinitionSelection: DynamicSelectModel = new DynamicSelectModel(collectionFormSubmissionDefinitionSelectionConfig); + + /** + * The dynamic form field used for correction submission definition selection + * @type {DynamicSelectModel} + */ + correctionSubmissionDefinitionSelection: DynamicSelectModel = new DynamicSelectModel(collectionFormCorrectionSubmissionDefinitionSelectionConfig); + + sharedWorkspaceChekbox: DynamicCheckboxModel = new DynamicCheckboxModel(collectionFormSharedWorkspaceCheckboxConfig); + /** * The dynamic form fields used for creating/editing a collection * @type {DynamicFormControlModel[]} @@ -94,6 +120,7 @@ export class CollectionFormComponent extends ComColFormComponent imp protected objectCache: ObjectCacheService, protected entityTypeService: EntityTypeDataService, protected chd: ChangeDetectorRef, + protected submissionDefinitionService: SubmissionDefinitionsConfigDataService, protected modalService: NgbModal) { super(formService, translate, notificationsService, authService, requestService, objectCache, modalService); } @@ -117,35 +144,76 @@ export class CollectionFormComponent extends ComColFormComponent imp initializeForm() { let currentRelationshipValue: MetadataValue[]; + let currentDefinitionValue: MetadataValue[]; + let currentCorrectionDefinitionValue: MetadataValue[]; + let currentSharedWorkspaceValue: MetadataValue[]; if (this.dso && this.dso.metadata) { currentRelationshipValue = this.dso.metadata['dspace.entity.type']; + currentDefinitionValue = this.dso.metadata['cris.submission.definition']; + currentCorrectionDefinitionValue = this.dso.metadata['cris.submission.definition-correction']; + currentSharedWorkspaceValue = this.dso.metadata['cris.workspace.shared']; } const entities$: Observable = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe( getFirstSucceededRemoteListPayload(), ); - // retrieve all entity types to populate the dropdowns selection - entities$.subscribe((entityTypes: ItemType[]) => { - - entityTypes = entityTypes.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE); - entityTypes.forEach((type: ItemType, index: number) => { - this.entityTypeSelection.add({ - disabled: false, - label: type.label, - value: type.label, - } as DynamicFormOptionConfig); - if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) { - this.entityTypeSelection.select(index); - this.entityTypeSelection.disabled = true; - } - }); + const definitions$: Observable = this.submissionDefinitionService + .findAll({ elementsPerPage: 100, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload(), + catchError(() => of([])), + ); + + // retrieve all entity types and submission definitions to populate the dropdowns selection + combineLatest([entities$, definitions$]) + .subscribe(([entityTypes, definitions]: [ItemType[], SubmissionDefinitionModel[]]) => { + + const sortedEntityTypes = entityTypes + .filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE) + .sort((a, b) => a.label.localeCompare(b.label)); + + sortedEntityTypes.forEach((type: ItemType, index: number) => { + this.entityTypeSelection.add({ + disabled: false, + label: type.label, + value: type.label, + } as DynamicFormOptionConfig); + if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) { + this.entityTypeSelection.select(index); + this.entityTypeSelection.disabled = true; + } + }); - this.formModel = entityTypes.length === 0 ? collectionFormModels : [...collectionFormModels, this.entityTypeSelection]; + definitions.forEach((definition: SubmissionDefinitionModel, index: number) => { + this.submissionDefinitionSelection.add({ + disabled: false, + label: definition.name, + value: definition.name, + } as DynamicFormOptionConfig); + this.correctionSubmissionDefinitionSelection.add({ + disabled: false, + label: definition.name, + value: definition.name, + } as DynamicFormOptionConfig); + if (currentDefinitionValue && currentDefinitionValue.length > 0 && currentDefinitionValue[0].value === definition.name) { + this.submissionDefinitionSelection.select(index); + } + if (currentCorrectionDefinitionValue && currentCorrectionDefinitionValue.length > 0 && currentCorrectionDefinitionValue[0].value === definition.name) { + this.correctionSubmissionDefinitionSelection.select(index); + } + }); - super.ngOnInit(); - this.chd.detectChanges(); - }); + this.formModel = entityTypes.length === 0 ? + [...collectionFormModels, this.submissionDefinitionSelection, this.correctionSubmissionDefinitionSelection, this.sharedWorkspaceChekbox] : + [...collectionFormModels, this.entityTypeSelection, this.submissionDefinitionSelection, this.correctionSubmissionDefinitionSelection, this.sharedWorkspaceChekbox]; + + super.ngOnInit(); + + if (currentSharedWorkspaceValue && currentSharedWorkspaceValue.length > 0) { + this.sharedWorkspaceChekbox.value = currentSharedWorkspaceValue[0].value === 'true'; + } + this.chd.detectChanges(); + }); } } diff --git a/src/app/collection-page/collection-form/collection-form.models.ts b/src/app/collection-page/collection-form/collection-form.models.ts index 90204c246f6..b7eaf36953c 100644 --- a/src/app/collection-page/collection-form/collection-form.models.ts +++ b/src/app/collection-page/collection-form/collection-form.models.ts @@ -1,4 +1,5 @@ import { + DynamicCheckboxModelConfig, DynamicFormControlModel, DynamicInputModel, DynamicSelectModelConfig, @@ -10,6 +11,38 @@ import { environment } from '../../../environments/environment'; export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig = { id: 'entityType', name: 'dspace.entity.type', + required: true, + disabled: false, + validators: { + required: null, + }, + errorMessages: { + required: 'collection.form.errors.entityType.required', + }, +}; + +export const collectionFormSubmissionDefinitionSelectionConfig: DynamicSelectModelConfig = { + id: 'submissionDefinition', + name: 'cris.submission.definition', + required: true, + disabled: false, + validators: { + required: null, + }, + errorMessages: { + required: 'collection.form.errors.submissionDefinition.required', + }, +}; +export const collectionFormCorrectionSubmissionDefinitionSelectionConfig: DynamicSelectModelConfig = { + id: 'correctionSubmissionDefinition', + name: 'cris.submission.definition-correction', + required: false, + disabled: false, +}; + +export const collectionFormSharedWorkspaceCheckboxConfig: DynamicCheckboxModelConfig = { + id: 'sharedWorkspace', + name: 'cris.workspace.shared', disabled: false, }; diff --git a/src/app/core/config/submission-definitions-config-data.service.ts b/src/app/core/config/submission-definitions-config-data.service.ts new file mode 100644 index 00000000000..1748f57cac7 --- /dev/null +++ b/src/app/core/config/submission-definitions-config-data.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { FollowLinkConfig } from '@dspace/core/shared/follow-link-config.model'; +import { Observable } from 'rxjs'; +import { + mergeMap, + take, +} from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; +import { ConfigObject } from './models/config.model'; + +@Injectable({ providedIn: 'root' }) +export class SubmissionDefinitionsConfigDataService extends ConfigDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('submissiondefinitions', requestService, rdbService, objectCache, halService); + } + + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.getBrowseEndpoint(options).pipe( + take(1), + mergeMap((href: string) => super.findListByHref(href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } +} diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts index 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/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 d18c3ec5e95..707f8be12c7 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 @@ -45,13 +45,14 @@ export class JsonPatchOperationsBuilder { * A boolean representing if the value to be added is the first of an array * @param plain * A boolean representing if the value to be added is a plain text value + * @param languages */ - add(path: JsonPatchOperationPathObject, value, first = false, plain = false) { + add(path: JsonPatchOperationPathObject, value, first = false, plain = false, languages: string[] = null) { this.store.dispatch( new NewPatchAddOperationAction( path.rootElement, path.subRootElement, - path.path, this.prepareValue(value, plain, first))); + path.path, this.prepareValue(value, plain, first, languages))); } /** @@ -63,8 +64,9 @@ 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 language */ - replace(path: JsonPatchOperationPathObject, value, plain = false) { + replace(path: JsonPatchOperationPathObject, value, plain = false, language = null) { if (hasNoValue(value) || (typeof value === 'object' && hasNoValue(value.value))) { this.remove(path); } else { @@ -73,7 +75,7 @@ export class JsonPatchOperationsBuilder { path.rootElement, path.subRootElement, path.path, - this.prepareValue(value, plain, false))); + this.prepareValue(value, plain, false, language))); } } @@ -124,7 +126,7 @@ export class JsonPatchOperationsBuilder { path.path)); } - protected prepareValue(value: any, plain: boolean, first: boolean) { + protected prepareValue(value: any, plain: boolean, first: boolean, languages: string[] = null) { let operationValue: any = null; if (hasValue(value)) { if (plain) { diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index e155117d5a6..fa4f4a37115 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -126,6 +126,20 @@ export class DSpaceObject extends ListableObject implements CacheableObject { return Metadata.all(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } + + /** + * Gets all matching metadata in this DSpaceObject, up to a limit. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {number} limit The maximum number of results to return. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue[]} the matching values or an empty array. + */ + limitedMetadata(keyOrKeys: string | string[], limit: number, valueFilter?: MetadataValueFilter): MetadataValue[] { + return Metadata.all(this.metadata, keyOrKeys, null, valueFilter, false, limit); + } + + /** * Like [[allMetadata]], but only returns string values. * 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 0362b3dd4e7..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 @@ -10,6 +10,7 @@ import { MetadataValueInterface, VIRTUAL_METADATA_PREFIX, } from '../../metadata.models'; +import { Metadata } from '../../metadata.utils'; import { PLACEHOLDER_PARENT_METADATA } from '../ds-dynamic-form-constants'; export interface OtherInformation { @@ -32,23 +33,28 @@ 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 (authority != null && (isEmpty(confidence) || confidence === -1)) { + if (Metadata.hasValidAuthority(authority) && (isEmpty(confidence) || confidence === -1)) { this.confidence = ConfidenceType.CF_ACCEPTED; } else if (isNotEmpty(confidence)) { this.confidence = confidence; @@ -60,7 +66,7 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { if (isNotEmpty(metadata)) { this.metadata = metadata; } - + this.source = source; this.otherInformation = otherInformation; } @@ -68,7 +74,7 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { * Returns true if this this object has an authority value */ hasAuthority(): boolean { - return isNotEmpty(this.authority); + return Metadata.hasValidAuthority(this.authority); } /** @@ -99,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/form/models/form-field.model.ts b/src/app/core/shared/form/models/form-field.model.ts index 5359ff5a99b..2d6b6e441f8 100644 --- a/src/app/core/shared/form/models/form-field.model.ts +++ b/src/app/core/shared/form/models/form-field.model.ts @@ -126,6 +126,9 @@ export class FormFieldModel { @autoserialize value: any; + /** + * The visibility object for this field + */ @autoserialize visibility: SectionVisibility; } diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 06fc0b01e84..33bf918bb49 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -99,6 +99,13 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject @autoserializeAs(Boolean, 'withdrawn') isWithdrawn: boolean; + /** + * A string representing the entity type of this Item + */ + @autoserializeAs(String, 'entityType') + entityType: string; + + /** * The {@link HALLink}s for this Item */ diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index 15cfeb285e6..2082c645e33 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -6,6 +6,7 @@ import { } from 'cerialize'; import { v4 as uuidv4 } from 'uuid'; + export const VIRTUAL_METADATA_PREFIX = 'virtual::'; /** A single metadata value and its properties. */ @@ -56,6 +57,9 @@ export class MetadataValue implements MetadataValueInterface { @autoserialize confidence: number; + /** The security level value */ + @autoserialize + securityLevel: number; } /** Constraints for matching metadata values. */ @@ -74,6 +78,10 @@ export interface MetadataValueFilter { /** Whether the value constraint should match as a substring. */ substring?: boolean; + /** + * Whether to negate the filter + */ + negate?: boolean; } export class MetadatumViewModel { @@ -100,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/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index f3ca5d82e7d..0333d114cad 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -50,11 +50,11 @@ const multiViewModelList = [ { key: 'foo', ...bar, order: 0 }, ]; -const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, hitHighlights, expected, filter?) => { +const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, hitHighlights, expected, filter?, limit?: number) => { const keys = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys))) + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { - const result = fn(mapOrMaps, keys, hitHighlights, filter); + const result = fn(mapOrMaps, keys, hitHighlights, filter, true, limit); let shouldReturn; if (resultKind === 'boolean') { shouldReturn = expected; @@ -62,7 +62,8 @@ const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, hitHighlights, expecte shouldReturn = 'undefined'; } else if (expected instanceof Array) { shouldReturn = 'an array with ' + expected.length + ' ' + (expected.length > 1 ? 'ordered ' : '') - + resultKind + (expected.length !== 1 ? 's' : ''); + + resultKind + (expected.length !== 1 ? 's' : '') + + (isUndefined(limit) ? '' : ' (limited to ' + limit + ')'); } else { shouldReturn = 'a ' + resultKind; } @@ -255,4 +256,60 @@ describe('Metadata', () => { }); + describe('all method with limit', () => { + const testAllWithLimit = (mapOrMaps, keyOrKeys, expected, limit) => + testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, undefined, expected, undefined, limit); + + describe('with multiMap and limit', () => { + testAllWithLimit(multiMap, 'dc.title', [dcTitle1], 1); + }); + }); + + describe('hasValue method', () => { + const testHasValue = (value, expected) => + testMethod(Metadata.hasValue, 'boolean', value, undefined, undefined, expected); + + describe('with undefined value', () => { + testHasValue(undefined, false); + }); + describe('with null value', () => { + testHasValue(null, false); + }); + describe('with string value', () => { + testHasValue('test', true); + }); + describe('with empty string value', () => { + testHasValue('', false); + }); + describe('with undefined value for a MetadataValue object', () => { + const value: Partial = { + value: undefined, + }; + testHasValue(value, false); + }); + describe('with null value for a MetadataValue object', () => { + const value: Partial = { + value: null, + }; + testHasValue(value, false); + }); + describe('with empty string for a MetadataValue object', () => { + const value: Partial = { + value: '', + }; + testHasValue(value, false); + }); + describe('with value for a MetadataValue object', () => { + const value: Partial = { + value: 'test', + }; + testHasValue(value, true); + }); + describe('with a generic object', () => { + const value: any = { + test: 'test', + }; + testHasValue(value, true); + }); + }); }); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index b44362a3ff5..b7f1db6476a 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,11 +1,15 @@ import { + hasValue, + isEmpty, isNotEmpty, isNotUndefined, isUndefined, } from '@dspace/shared/utils/empty.util'; import escape from 'lodash/escape'; import groupBy from 'lodash/groupBy'; +import isObject from 'lodash/isObject'; import sortBy from 'lodash/sortBy'; +import { validate as uuidValidate } from 'uuid'; import { MetadataMapInterface, @@ -14,6 +18,11 @@ import { MetadatumViewModel, } from './metadata.models'; + + +export const AUTHORITY_GENERATE = 'will be generated::'; +export const AUTHORITY_REFERENCE = 'will be referenced::'; +export const PLACEHOLDER_VALUE = '#PLACEHOLDER_PARENT_METADATA_VALUE#'; /** * Utility class for working with DSpace object metadata. * @@ -39,13 +48,13 @@ export class Metadata { * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue[]} the matching values or an empty array. */ - public static all(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue[] { + public static all(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean, limit?: number): MetadataValue[] { const matches: MetadataValue[] = []; if (isNotEmpty(hitHighlights)) { for (const mdKey of Metadata.resolveKeys(hitHighlights, keyOrKeys)) { if (hitHighlights[mdKey]) { for (const candidate of hitHighlights[mdKey]) { - if (Metadata.valueMatches(candidate as MetadataValue, filter)) { + if (Metadata.valueMatches(candidate as MetadataValue, filter) && (isEmpty(limit) || (hasValue(limit) && matches.length < limit))) { matches.push(candidate as MetadataValue); } } @@ -58,7 +67,7 @@ export class Metadata { for (const mdKey of Metadata.resolveKeys(metadata, keyOrKeys)) { if (metadata[mdKey]) { for (const candidate of metadata[mdKey]) { - if (Metadata.valueMatches(candidate as MetadataValue, filter)) { + if (Metadata.valueMatches(candidate as MetadataValue, filter) && (isEmpty(limit) || (hasValue(limit) && matches.length < limit))) { if (escapeHTML) { matches.push(Object.assign(new MetadataValue(), candidate, { value: escape(candidate.value), @@ -148,6 +157,40 @@ export class Metadata { return isNotUndefined(Metadata.first(metadata, keyOrKeys, hitHighlights, filter)); } + + /** + * Returns true if this Metadatum's authority key contains a reference + */ + public static hasAuthorityReference(authority: string): boolean { + return hasValue(authority) && (typeof authority === 'string' && (authority.startsWith(AUTHORITY_GENERATE) || authority.startsWith(AUTHORITY_REFERENCE))); + } + + /** + * Returns true if this Metadatum's authority key is a valid + */ + public static hasValidAuthority(authority: string): boolean { + return hasValue(authority) && !Metadata.hasAuthorityReference(authority); + } + + /** + * Returns true if this Metadatum's authority key is a valid UUID + */ + public static hasValidItemAuthority(authority: string): boolean { + return hasValue(authority) && uuidValidate(authority); + } + + /** + * Returns true if this Metadatum's value is defined + */ + public static hasValue(value: MetadataValue|string): boolean { + if (isEmpty(value)) { + return false; + } + if (isObject(value) && value.hasOwnProperty('value')) { + return isNotEmpty(value.value); + } + return true; + } /** * Checks if a value matches a filter. * @@ -169,11 +212,14 @@ export class Metadata { fValue = filter.value.toLowerCase(); mValue = mdValue.value.toLowerCase(); } + let result: boolean; + if (filter.substring) { - return mValue.includes(fValue); + result = mValue.includes(fValue); } else { - return mValue === fValue; + result = mValue === fValue; } + return filter.negate ? !result : result; } return true; } 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-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..20c5c66e6c7 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -22,6 +22,7 @@ import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/rest-request.model'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { FormFieldMetadataValueObject } from '../shared/form/models/form-field-metadata-value.model'; +import { EditItem } from './models/edititem.model'; import { SubmissionObject } from './models/submission-object.model'; import { WorkflowItem } from './models/workflowitem.model'; import { WorkspaceItem } from './models/workspaceitem.model'; @@ -56,6 +57,7 @@ export function normalizeSectionData(obj: any, objIndex?: number) { result = new FormFieldMetadataValueObject( obj.value, obj.language, + obj.securityLevel, obj.authority, (obj.display || obj.value), obj.place || objIndex, @@ -144,7 +146,8 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService // item = Object.assign({}, item); // In case data is an Instance of WorkspaceItem normalize field value of all the section of type form if (item instanceof WorkspaceItem - || item instanceof WorkflowItem) { + || item instanceof WorkflowItem + || item instanceof EditItem) { if (item.sections) { const precessedSection = Object.create({}); // Iterate over all workspaceitem's sections diff --git a/src/app/core/submission/submission-rest.service.ts b/src/app/core/submission/submission-rest.service.ts index 12f94a02d2b..897882d8d17 100644 --- a/src/app/core/submission/submission-rest.service.ts +++ b/src/app/core/submission/submission-rest.service.ts @@ -91,10 +91,15 @@ export class SubmissionRestService { * The identifier for the object * @param collectionId * The owning collection for the object + * @param projections */ - protected getEndpointByIDHref(endpoint, resourceID, collectionId?: string): string { + protected getEndpointByIDHref(endpoint, resourceID, collectionId?: string, projections: string[] = []): string { let url = isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; url = new URLCombiner(url, '?embed=item,sections,collection').toString(); + + projections.forEach((projection) => { + url = new URLCombiner(url, '&projection=' + projection).toString(); + }); if (collectionId) { url = new URLCombiner(url, `&owningCollection=${collectionId}`).toString(); } @@ -135,9 +140,9 @@ export class SubmissionRestService { * @return Observable * server response */ - public getDataById(linkName: string, id: string, useCachedVersionIfAvailable = false): Observable { + public getDataById(linkName: string, id: string, useCachedVersionIfAvailable = false, projections: string[] = []): Observable { return this.halService.getEndpoint(linkName).pipe( - map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id, null, projections)), filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), mergeMap((endpointURL: string) => { diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts index a8995db5a7e..20d29f8ccec 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts @@ -1,10 +1,16 @@ -import { typedObject } from '@dspace/core/cache/builders/build-decorators'; +import { + link, + typedObject, +} from '@dspace/core/cache/builders/build-decorators'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { RemoteData } from '@dspace/core/data/remote-data'; import { HALLink } from '@dspace/core/shared/hal-link.model'; import { autoserialize, deserialize, inheritSerialization, } from 'cerialize'; +import { Observable } from 'rxjs'; import { VOCABULARY_ENTRY_DETAIL } from './vocabularies.resource-type'; import { VocabularyEntry } from './vocabulary-entry.model'; @@ -37,7 +43,21 @@ export class VocabularyEntryDetail extends VocabularyEntry { self: HALLink; vocabulary: HALLink; parent: HALLink; - children + children: HALLink; }; + /** + * The submitter for this SubmissionObject + * Will be undefined unless the submitter {@link HALLink} has been resolved. + */ + @link(VOCABULARY_ENTRY_DETAIL) + parent?: Observable>; + + /** + * The submitter for this SubmissionObject + * Will be undefined unless the submitter {@link HALLink} has been resolved. + */ + @link(VOCABULARY_ENTRY_DETAIL, true) + children?: Observable>>; + } diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts index 10ae406cf00..7cb7aa1c428 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts @@ -1,6 +1,7 @@ import { typedObject } from '@dspace/core/cache/builders/build-decorators'; import { GenericConstructor } from '@dspace/core/shared/generic-constructor'; import { HALLink } from '@dspace/core/shared/hal-link.model'; +import { Metadata } from '@dspace/core/shared/metadata.utils'; import { excludeFromEquals } from '@dspace/core/utilities/equals.decorators'; import { isNotEmpty } from '@dspace/shared/utils/empty.util'; import { @@ -44,6 +45,12 @@ export class VocabularyEntry extends ListableObject { @autoserialize otherInformation: OtherInformation; + /** + * A value representing security level value of the metadata + */ + @autoserialize + securityLevel: number; + /** * A string representing the kind of vocabulary entry */ @@ -66,7 +73,7 @@ export class VocabularyEntry extends ListableObject { * @return boolean */ hasAuthority(): boolean { - return isNotEmpty(this.authority); + return Metadata.hasValidAuthority(this.authority); } /** diff --git a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts index 7c586736578..b49dfa91aaa 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts @@ -8,7 +8,9 @@ import { isNotEmpty } from '@dspace/shared/utils/empty.util'; */ export class VocabularyFindOptions extends FindListOptions { - constructor(public query: string = '', + constructor(public collection, + public metadata, + public query: string = '', public filter?: string, public exact?: boolean, public entryID?: string, @@ -19,7 +21,12 @@ export class VocabularyFindOptions extends FindListOptions { super(); const searchParams = []; - + if (isNotEmpty(metadata)) { + searchParams.push(new RequestParam('metadata', metadata)); + } + if (isNotEmpty(collection)) { + searchParams.push(new RequestParam('collection', collection)); + } if (isNotEmpty(query)) { searchParams.push(new RequestParam('query', query)); } diff --git a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts index 7f54b8599d6..f7b708f5157 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts @@ -8,14 +8,28 @@ export class VocabularyOptions { */ name: string; + /** + * The metadata field name (e.g. "dc.type") for which the vocabulary is used: + */ + metadata: string; + + /** + * The uuid of the collection where the item is being submitted + */ + scope: string; + /** * A boolean representing if value is closely related to a vocabulary entry or not */ closed: boolean; constructor(name: string, + metadata?: string, + scope?: string, closed: boolean = false) { this.name = name; + this.metadata = metadata; + this.scope = scope; this.closed = closed; } } diff --git a/src/app/core/submission/vocabularies/models/vocabulary.model.ts b/src/app/core/submission/vocabularies/models/vocabulary.model.ts index bc157950e82..7de580dc185 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary.model.ts @@ -19,6 +19,10 @@ import { } from './vocabularies.resource-type'; import { VocabularyEntry } from './vocabulary-entry.model'; +export interface VocabularyExternalSourceMap { + [metadata: string]: string; +} + /** * Model class for a Vocabulary */ @@ -56,6 +60,20 @@ export class Vocabulary implements CacheableObject { @autoserialize preloadLevel: any; + /** + * If externalSource is available represent the entity type that can be use to create a new entity from + * this vocabulary + */ + @autoserialize + entity: string; + + /** + * A boolean variable that indicates whether the functionality of + * multiple value generation is enabled within a generator context. + */ + @autoserialize + multiValueOnGenerator: boolean; + /** * A string representing the kind of Vocabulary model */ diff --git a/src/app/core/submission/vocabularies/vocabulary-entry-details.data.service.ts b/src/app/core/submission/vocabularies/vocabulary-entry-details.data.service.ts index fb295e17392..36be243ad22 100644 --- a/src/app/core/submission/vocabularies/vocabulary-entry-details.data.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary-entry-details.data.service.ts @@ -65,6 +65,22 @@ export class VocabularyEntryDetailsDataService extends IdentifiableDataService[]): Observable> { + const href$ = this.getIDHrefObs(id, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Make a new FindListRequest with given search method * diff --git a/src/app/core/submission/vocabularies/vocabulary.data.service.ts b/src/app/core/submission/vocabularies/vocabulary.data.service.ts index 94316a2b10e..84d1ba871f3 100644 --- a/src/app/core/submission/vocabularies/vocabulary.data.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.data.service.ts @@ -16,7 +16,10 @@ import { FindAllDataImpl, } from '../../data/base/find-all-data'; import { IdentifiableDataService } from '../../data/base/identifiable-data.service'; -import { SearchDataImpl } from '../../data/base/search-data'; +import { + SearchData, + 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'; @@ -29,10 +32,10 @@ import { Vocabulary } from './models/vocabulary.model'; * Data service to retrieve vocabularies from the REST server. */ @Injectable({ providedIn: 'root' }) -export class VocabularyDataService extends IdentifiableDataService implements FindAllData { +export class VocabularyDataService extends IdentifiableDataService implements FindAllData, SearchData { protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection'; - private findAllData: FindAllData; + private findAllData: FindAllDataImpl; private searchData: SearchDataImpl; constructor( @@ -65,6 +68,50 @@ export class VocabularyDataService extends IdentifiableDataService i return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @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 + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.findAllData.getFindAllHref(options, linkPath, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + /** * Return the controlled vocabulary configured for the specified metadata and collection if any (/submission/vocabularies/search/{@link searchByMetadataAndCollectionPath}?metadata=<>&collection=<>) * @param metadataField metadata field to search diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 37ea7e7fdf0..a89432618c2 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -100,6 +100,33 @@ describe('VocabularyService', () => { type: 'vocabularyEntry', }; + const entryDetailRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue`; + const entryDetailParentRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/parent`; + const entryDetailChildrenRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/children`; + + const vocabularyEntryDetail: any = { + authority: 'authorityId', + display: 'test', + value: 'test', + otherInformation: { + id: 'authorityId', + hasChildren: 'true', + note: 'Familjeforskning', + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: entryDetailRequestURL, + }, + parent: { + href: entryDetailParentRequestURL, + }, + children: { + href: entryDetailChildrenRequestURL, + }, + }, + }; + const vocabularyEntryParentDetail: any = { authority: 'authorityId2', display: 'testParent', @@ -172,9 +199,7 @@ describe('VocabularyService', () => { const endpointURL = `https://rest.api/rest/api/submission/vocabularies`; const requestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}`; const entryDetailEndpointURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails`; - const entryDetailRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue`; - const entryDetailParentRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/parent`; - const entryDetailChildrenRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/children`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; const vocabularyId = 'types'; const metadata = 'dc.type'; @@ -187,6 +212,8 @@ describe('VocabularyService', () => { const entryByIDRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?entryID=${entryID}`; const vocabularyOptions: VocabularyOptions = { name: vocabularyId, + metadata: metadata, + scope: collectionUUID, closed: false, }; const pageInfo = new PageInfo(); @@ -199,6 +226,7 @@ describe('VocabularyService', () => { const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary); const vocabularyRD$ = createSuccessfulRemoteDataObject$(vocabulary); const vocabularyEntriesRD = createSuccessfulRemoteDataObject$(paginatedListEntries); + const vocabularyEntryDetailRD$ = createSuccessfulRemoteDataObject$(vocabularyEntryDetail); const vocabularyEntryDetailParentRD = createSuccessfulRemoteDataObject(vocabularyEntryParentDetail); const vocabularyEntryChildrenRD = createSuccessfulRemoteDataObject(childrenPaginatedList); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); @@ -244,6 +272,7 @@ describe('VocabularyService', () => { removeByHrefSubstring: {}, getByHref: of(responseCacheEntry), getByUUID: of(responseCacheEntry), + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring'), }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: hot('a|', { @@ -252,6 +281,7 @@ describe('VocabularyService', () => { buildList: hot('a|', { a: paginatedListRD, }), + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring'), }); service = initTestService(); @@ -335,6 +365,24 @@ describe('VocabularyService', () => { expect(result).toBeObservable(expected); }); }); + + describe('searchVocabularyByMetadataAndCollection', () => { + it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { + scheduler.schedule(() => service.searchVocabularyByMetadataAndCollection(vocabularyOptions).subscribe()); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(searchRequestURL); + }); + + it('should return a RemoteData for the search', () => { + const result = service.searchVocabularyByMetadataAndCollection(vocabularyOptions); + const expected = cold('a|', { + a: vocabularyRD, + }); + expect(result).toBeObservable(expected); + }); + + }); }); describe('vocabulary entries', () => { @@ -429,6 +477,7 @@ describe('VocabularyService', () => { removeByHrefSubstring: {}, getByHref: of(responseCacheEntry), getByUUID: of(responseCacheEntry), + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring'), }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: hot('a|', { @@ -490,6 +539,10 @@ describe('VocabularyService', () => { }); describe('getEntryDetailParent', () => { + beforeEach(() => { + (service as any).vocabularyEntryDetailDataService.findById.and.returnValue(vocabularyEntryDetailRD$); + }); + it('should proxy the call to vocabularyDataService.getEntryDetailParent', () => { scheduler.schedule(() => service.getEntryDetailParent('testValue', hierarchicalVocabulary.id).subscribe()); scheduler.flush(); @@ -507,12 +560,18 @@ describe('VocabularyService', () => { }); describe('getEntryDetailChildren', () => { + beforeEach(() => { + (service as any).vocabularyEntryDetailDataService.findById.and.returnValue(vocabularyEntryDetailRD$); + }); + it('should proxy the call to vocabularyDataService.getEntryDetailChildren', () => { const options: VocabularyFindOptions = new VocabularyFindOptions( null, null, null, null, + null, + null, pageInfo.elementsPerPage, pageInfo.currentPage, ); @@ -538,6 +597,8 @@ describe('VocabularyService', () => { null, null, null, + null, + null, pageInfo.elementsPerPage, pageInfo.currentPage, ); @@ -562,7 +623,7 @@ describe('VocabularyService', () => { it('should remove requests on the data service\'s endpoint', (done) => { service.clearSearchTopRequests(); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(`search/${(service as any).searchTopMethod}`); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(`search/${(service as any).searchTopMethod}`); done(); }); }); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 35f06b5c9c3..f14e540ce64 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -1,7 +1,15 @@ import { Injectable } from '@angular/core'; -import { isNotEmpty } from '@dspace/shared/utils/empty.util'; -import { Observable } from 'rxjs'; +import { createFailedRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { + hasValue, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { + Observable, + of, +} from 'rxjs'; +import { + first, map, mergeMap, switchMap, @@ -17,6 +25,7 @@ import { FollowLinkConfig, } from '../../shared/follow-link-config.model'; import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload, } from '../../shared/operators'; @@ -34,6 +43,7 @@ import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.da */ @Injectable({ providedIn: 'root' }) export class VocabularyService { + protected searchByMetadataAndCollectionMethod = 'byMetadataAndCollection'; protected searchTopMethod = 'top'; constructor( @@ -123,6 +133,8 @@ export class VocabularyService { getVocabularyEntries(vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { const options: VocabularyFindOptions = new VocabularyFindOptions( + vocabularyOptions.scope, + vocabularyOptions.metadata, null, null, null, @@ -150,6 +162,8 @@ export class VocabularyService { */ getVocabularyEntriesByValue(value: string, exact: boolean, vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { const options: VocabularyFindOptions = new VocabularyFindOptions( + vocabularyOptions.scope, + vocabularyOptions.metadata, null, value, exact, @@ -166,6 +180,43 @@ export class VocabularyService { } + /** + * Get the display value for a vocabulary item, given the vocabulary name and the item value + * @param vocabularyName + * @param value + */ + getPublicVocabularyEntryByValue(vocabularyName: string, value: string): Observable>> { + const params: RequestParam[] = [ + new RequestParam('filter', value), + new RequestParam('exact', 'true'), + ]; + const options = Object.assign(new FindListOptions(), { + searchParams: params, + elementsPerPage: 1, + }); + const href$ = this.vocabularyDataService.getFindAllHref(options, vocabularyName + '/entries'); + return this.vocabularyEntryDetailDataService.findListByHref(href$); + } + + /** + * Get the display value for a hierarchical vocabulary item, + * given the vocabulary name and the entryID of that vocabulary-entry + * + * @param vocabularyName + * @param entryID + */ + getPublicVocabularyEntryByID(vocabularyName: string, entryID: string): Observable>> { + const params: RequestParam[] = [ + new RequestParam('entryID', entryID), + ]; + const options = Object.assign(new FindListOptions(), { + searchParams: params, + elementsPerPage: 1, + }); + const href$ = this.vocabularyDataService.getFindAllHref(options, vocabularyName + '/entries'); + return this.vocabularyEntryDetailDataService.findListByHref(href$); + } + /** * Return the {@link VocabularyEntry} list for a given value * @@ -199,6 +250,8 @@ export class VocabularyService { getVocabularyEntryByID(ID: string, vocabularyOptions: VocabularyOptions): Observable { const pageInfo = new PageInfo(); const options: VocabularyFindOptions = new VocabularyFindOptions( + vocabularyOptions.scope, + vocabularyOptions.metadata, null, null, null, @@ -222,6 +275,23 @@ export class VocabularyService { ); } + /** + * Return the controlled {@link Vocabulary} configured for the specified metadata and collection if any. + * + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entry belongs + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + searchVocabularyByMetadataAndCollection(vocabularyOptions: VocabularyOptions, ...linksToFollow: FollowLinkConfig[]): Observable> { + const options: VocabularyFindOptions = new VocabularyFindOptions(vocabularyOptions.scope, vocabularyOptions.metadata); + + return this.vocabularyDataService.getSearchByHref(this.searchByMetadataAndCollectionMethod, options, ...linksToFollow).pipe( + first((href: string) => hasValue(href)), + mergeMap((href: string) => this.vocabularyDataService.findByHref(href)), + ); + } + /** * Returns an observable of {@link RemoteData} of a {@link VocabularyEntryDetail}, based on an href, with a list of {@link FollowLinkConfig}, * to automatically resolve {@link HALLink}s of the {@link VocabularyEntryDetail} @@ -256,7 +326,8 @@ export class VocabularyService { * Return an observable that emits VocabularyEntryDetail object */ findEntryDetailById(id: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, constructId: boolean = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - const findId: string = (constructId ? `${name}:${id}` : id); + // add the vocabulary name as prefix if doesn't exist + const findId = (!constructId || id.startsWith(`${name}:`)) ? id : `${name}:${id}`; return this.vocabularyEntryDetailDataService.findById(findId, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } @@ -275,11 +346,20 @@ export class VocabularyService { * Return an observable that emits a PaginatedList of VocabularyEntryDetail */ getEntryDetailParent(value: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - const linkPath = `${name}:${value}/parent`; - - return this.vocabularyEntryDetailDataService.getBrowseEndpoint().pipe( - map((href: string) => `${href}/${linkPath}`), - mergeMap((href) => this.vocabularyEntryDetailDataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + return this.findEntryDetailById(value, name, useCachedVersionIfAvailable, reRequestOnStale, true, ...linksToFollow).pipe( + getFirstCompletedRemoteData(), + switchMap((entryRD: RemoteData) => { + if (entryRD.hasSucceeded) { + return this.vocabularyEntryDetailDataService.findByHref( + entryRD.payload._links.parent.href, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow, + ); + } else { + return of(createFailedRemoteDataObject(entryRD.errorMessage)); + } + }), ); } @@ -304,13 +384,27 @@ export class VocabularyService { null, null, null, + null, + null, pageInfo.elementsPerPage, pageInfo.currentPage, ); - return this.vocabularyEntryDetailDataService.getBrowseEndpoint().pipe( - map(href => `${href}/${name}:${value}/children`), - switchMap(href => this.vocabularyEntryDetailDataService.findListByHref(href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + return this.findEntryDetailById(value, name, useCachedVersionIfAvailable, reRequestOnStale, true, ...linksToFollow).pipe( + getFirstCompletedRemoteData(), + switchMap((entryRD: RemoteData) => { + if (entryRD.hasSucceeded) { + return this.vocabularyEntryDetailDataService.findListByHref( + entryRD.payload._links.children.href, + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow, + ); + } else { + return of(createFailedRemoteDataObject>(entryRD.errorMessage)); + } + }), ); } @@ -333,6 +427,8 @@ export class VocabularyService { null, null, null, + null, + null, pageInfo.elementsPerPage, pageInfo.currentPage, ); @@ -344,7 +440,7 @@ export class VocabularyService { * Clear all search Top Requests */ clearSearchTopRequests(): void { - this.requestService.removeByHrefSubstring(`search/${this.searchTopMethod}`); + this.requestService.setStaleByHrefSubstring(`search/${this.searchTopMethod}`); } } 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-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 ecaf2aa7443..15dc97f7da4 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 @@ -4,6 +4,7 @@
{{ 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-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 becb7b5278b..e9358c9e186 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 three headers', () => { - expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(3); + it('should display four headers', () => { + expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(4); }); }); 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 d83bacecb21..ce9ed9c481d 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 @@ -12,6 +12,12 @@ max-width: var(--ds-dso-edit-lang-width); } +.ds-authority-cell { + min-width: var(--ds-dso-edit-authority-width); + max-width: var(--ds-dso-edit-authority-width); +} + + .ds-edit-cell { min-width: var(--ds-dso-edit-actions-width); } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts index 9eadba92488..e06fdc74f30 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts @@ -5,6 +5,7 @@ import { } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ItemDataService } from '@dspace/core/data/item-data.service'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; 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'; @@ -15,15 +16,19 @@ import { Item } from '@dspace/core/shared/item.model'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; +import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { RegistryService } from '../../../../admin/admin-registries/registry/registry.service'; import { DynamicOneboxModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; import { DsDynamicScrollableDropdownComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { DynamicScrollableDropdownModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; +import { SubmissionService } from '../../../../submission/submission.service'; import { DsoEditMetadataValue } from '../../dso-edit-metadata-form'; import { DsoEditMetadataAuthorityFieldComponent } from './dso-edit-metadata-authority-field.component'; @@ -57,6 +62,8 @@ describe('DsoEditMetadataAuthorityFieldComponent', () => { scrollable: true, hierarchical: false, preloadLevel: 0, + entity: 'Person', + multiValueOnGenerator: false, type: 'vocabulary', _links: { self: { @@ -75,6 +82,8 @@ describe('DsoEditMetadataAuthorityFieldComponent', () => { hierarchical: true, preloadLevel: 2, type: 'vocabulary', + entity: 'Publication', + multiValueOnGenerator: false, _links: { self: { href: 'self', @@ -91,6 +100,8 @@ describe('DsoEditMetadataAuthorityFieldComponent', () => { scrollable: false, hierarchical: false, preloadLevel: 0, + entity: 'Person', + multiValueOnGenerator: false, type: 'vocabulary', _links: { self: { @@ -153,6 +164,10 @@ describe('DsoEditMetadataAuthorityFieldComponent', () => { { provide: ItemDataService, useValue: itemService }, { provide: RegistryService, useValue: registryService }, { provide: NotificationsService, useValue: notificationsService }, + { provide: FormBuilderService }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + provideMockStore({ initialState: { core: { index: { } } } }), ], }).overrideComponent(DsoEditMetadataAuthorityFieldComponent, { remove: { 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 4ce0e248c1c..ae5ae79ef9a 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -18,7 +18,7 @@ class="w-100"> } - @if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE) { + @if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceType.CF_UNSET && mdValue.newValue.confidence !== ConfidenceType.CF_NOVALUE) {
{{ mdValue.newValue.language }}
} +
+ @if (!mdValue.editing) { +
{{ mdValue.newValue.authority }}
+ } + @if(mdValue.editing) { + + } +
+
+
+ @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 0e2c4b5d217..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 @@ -8,17 +8,27 @@ import { waitForAsync, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; +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,13 +100,18 @@ describe('DsoEditMetadataValueComponent', () => { authority: undefined, }); editMetadataValue = new DsoEditMetadataValue(metadataValue); + dso = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'fake-dso-url/dso' }, + }, + }); initServices(); await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - RouterModule.forRoot([]), + RouterTestingModule.withRoutes([]), DsoEditMetadataValueComponent, VarDirective, BtnDisabledDirective, @@ -83,14 +120,16 @@ 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], }) .overrideComponent(DsoEditMetadataValueComponent, { remove: { imports: [ - DsoEditMetadataValueFieldLoaderComponent, ThemedTypeBadgeComponent, + DsoEditMetadataValueFieldLoaderComponent, ], }, }) @@ -101,7 +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(); }); @@ -111,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 2af25e39c3b..65e00fbde5e 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -7,38 +7,69 @@ import { NgClass, } from '@angular/common'; import { + ChangeDetectorRef, Component, 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 } from '@ngx-translate/core'; import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, EMPTY, Observable, + of, + Subscription, } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + distinctUntilChanged, + map, + shareReplay, + switchMap, + take, +} from 'rxjs/operators'; +import { RegistryService } from '../../../admin/admin-registries/registry/registry.service'; +import { EditMetadataSecurityComponent } from '../../../item-page/edit-item-page/edit-metadata-security/edit-metadata-security.component'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { AuthorityConfidenceStateDirective } from '../../../shared/form/directives/authority-confidence-state.directive'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; @@ -63,6 +94,7 @@ import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-v CdkDragHandle, DebounceDirective, DsoEditMetadataValueFieldLoaderComponent, + EditMetadataSecurityComponent, FormsModule, NgbTooltip, NgClass, @@ -74,7 +106,7 @@ import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-v /** * Component displaying a single editable row for a metadata value */ -export class DsoEditMetadataValueComponent implements OnInit, OnChanges { +export class DsoEditMetadataValueComponent implements OnInit, OnChanges, OnDestroy { @Input() context: Context; @@ -83,16 +115,44 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { * Also used to determine metadata-representations in case of virtual metadata */ @Input() dso: DSpaceObject; + /** + * Editable metadata value to show + */ + @Input() mdValue: DsoEditMetadataValue; /** - * The metadata field that is being edited + * The metadata security configuration for the entity. */ - @Input() mdField: string; + @Input() + set metadataSecurityConfiguration(metadataSecurityConfiguration: MetadataSecurityConfiguration) { + this._metadataSecurityConfiguration$.next(metadataSecurityConfiguration); + } + + get metadataSecurityConfiguration() { + return this._metadataSecurityConfiguration$.value; + } + + protected readonly _metadataSecurityConfiguration$ = + new BehaviorSubject(null); /** - * Editable metadata value to show + * The metadata field to display a value for */ - @Input() mdValue: DsoEditMetadataValue; + @Input() + set mdField(mdField: string) { + this._mdField$.next(mdField); + } + + get mdField() { + return this._mdField$.value; + } + + protected readonly _mdField$ = new BehaviorSubject(null); + + /** + * Flag whether this is a new metadata field or exists already + */ + @Input() isNewMdField = false; /** * Type of DSO we're displaying values for @@ -137,6 +197,16 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { */ @Output() dragging: EventEmitter = new EventEmitter(); + /** + * Emits the new value of security level + */ + @Output() updateSecurityLevel: EventEmitter = new EventEmitter(); + + /** + * Emits true when the metadata has security settings + */ + @Output() hasSecurityLevel: EventEmitter = new EventEmitter(false); + /** * The DsoEditMetadataChangeType enumeration for access in the component's template * @type {DsoEditMetadataChangeType} @@ -157,30 +227,72 @@ 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, ) { } ngOnInit(): void { this.initVirtualProperties(); + + this.sub = combineLatest([ + this._mdField$, + this._metadataSecurityConfiguration$, + ]).subscribe(([mdField, metadataSecurityConfig]) => this.initSecurityLevel(mdField, metadataSecurityConfig)); + + this.canShowMetadataSecurity$ = + combineLatest([ + this._mdField$.pipe(distinctUntilChanged()), + this.mdSecurityConfigLevel$, + ]).pipe( + map(([mdField, securityConfigLevel]) => hasValue(mdField) && this.hasSecurityChoice(securityConfigLevel)), + shareReplay({ refCount: false, bufferSize: 1 }), + ); } - ngOnChanges(changes: SimpleChanges): void { - if (changes.mdField) { - this.fieldType$ = this.getFieldType(); - } + /** + * Emits the edit event + * @param securityLevel + */ + changeSelectedSecurity(securityLevel: number) { + this.updateSecurityLevel.emit(securityLevel); } /** @@ -202,6 +314,30 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { ); } + initSecurityLevel(mdField: string, metadataSecurityConfig: MetadataSecurityConfiguration) { + let appliedSecurity: number[] = []; + if (hasValue(metadataSecurityConfig)) { + if (metadataSecurityConfig?.metadataCustomSecurity[mdField]) { + appliedSecurity = metadataSecurityConfig.metadataCustomSecurity[mdField]; + } else if (metadataSecurityConfig?.metadataSecurityDefault) { + appliedSecurity = metadataSecurityConfig.metadataSecurityDefault; + } + } + this.mdSecurityConfigLevel$.next(appliedSecurity); + } + + /** + * Emits the value for the metadata security existence + */ + hasSecurityMetadata(event: boolean) { + this.hasSecurityLevel.emit(event); + } + + private hasSecurityChoice(securityConfigLevel: number[]) { + return securityConfigLevel?.length > 1; + } + + /** * Retrieves the {@link EditMetadataValueFieldType} to be displayed for the current field while in edit mode. */ @@ -219,4 +355,60 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { ); } + /** + * Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata + * that uses a controlled vocabulary and update the related properties + * + * @param {SimpleChanges} changes + */ + ngOnChanges(changes: SimpleChanges): void { + if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { + if (isNotEmpty(changes.mdField.currentValue) ) { + if (isNotEmpty(changes.mdField.previousValue) && + changes.mdField.previousValue !== changes.mdField.currentValue) { + // Clear authority value in case it has been assigned with the previous metadataField used + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + + // Only ask if the current mdField have a period character to reduce request + if (changes.mdField.currentValue.includes('.')) { + this.validateMetadataField().subscribe((isValid: boolean) => { + if (isValid) { + this.cdr.detectChanges(); + } + }); + } + } + } + } + + /** + * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error + */ + validateMetadataField(): Observable { + return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd) => { + if (rd.hasSucceeded) { + return of(rd).pipe( + metadataFieldsToString(), + take(1), + map((fields: string[]) => fields.indexOf(this.mdField) > -1), + ); + } else { + this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); + return [false]; + } + }), + ); + } + + + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } + } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index d9f62d2a5f4..d2a59061a36 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -47,6 +47,7 @@ [dsoType]="dsoType"> + (undo)="form.newValue = undefined" + (updateSecurityLevel)="onUpdateSecurityLevel($event)" + (hasSecurityLevel)="hasSecurityLevel($event)">
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts index c7d82285be1..2e8387cced8 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts @@ -20,8 +20,12 @@ import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { Item } from '@dspace/core/shared/item.model'; import { ITEM } from '@dspace/core/shared/item.resource-type'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { TestDataService } from '@dspace/core/testing/test-data-service.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { TranslateModule } from '@ngx-translate/core'; +import { mockSecurityConfig } from 'src/app/submission/utils/submission.mock'; import { AlertComponent } from '../../shared/alert/alert.component'; import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; @@ -43,6 +47,10 @@ const mockDataServiceMap: any = new Map([ [ITEM.value, () => import('@dspace/core/testing/test-data-service.mock').then(m => m.TestDataService)], ]); +const metadataSecurityConfigDataServiceSpy = jasmine.createSpyObj('metadataSecurityConfigDataService', { + findById: createSuccessfulRemoteDataObject$(mockSecurityConfig), +}); + describe('DsoEditMetadataComponent', () => { let component: DsoEditMetadataComponent; let fixture: ComponentFixture; @@ -100,6 +108,7 @@ describe('DsoEditMetadataComponent', () => { providers: [ { provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap }, { provide: NotificationsService, useValue: notificationsService }, + { provide: MetadataSecurityConfigurationService, useValue: metadataSecurityConfigDataServiceSpy }, ArrayMoveChangeAnalyzer, TestDataService, ], @@ -128,6 +137,10 @@ describe('DsoEditMetadataComponent', () => { fixture.detectChanges(); })); + it('should set security configuration object', () => { + expect(component.securitySettings$.value).toEqual(mockSecurityConfig); + }); + describe('when no changes have been made', () => { assertButton(ADD_BTN, true, false); assertButton(REINSTATE_BTN, false); @@ -202,20 +215,35 @@ describe('DsoEditMetadataComponent', () => { expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeNull(); }); }); + + it('should fetch security settings for Item', () => { + component.dso = Object.assign(new Item(), { + ...dso, + entityType: 'Person', + }); + component.getSecuritySettings().subscribe((securitySettings: MetadataSecurityConfiguration) => { + expect(securitySettings).toBeDefined(); + }); + }); }); function assertButton(name: string, exists: boolean, disabled: boolean = false): void { describe(`${name} button`, () => { let btn: DebugElement; - beforeEach(() => { + beforeEach(waitForAsync(() => { + fixture.detectChanges(); btn = fixture.debugElement.query(By.css(`#dso-${name}-btn`)); - }); + })); if (exists) { - it('should exist', () => { + it('form should be initialized', waitForAsync(() => { + expect(component.isFormInitialized$.value).toBeTrue(); + })); + + it('should exist', waitForAsync(() => { expect(btn).toBeTruthy(); - }); + })); it(`should${disabled ? ' ' : ' not '}be disabled`, () => { if (disabled) { @@ -227,9 +255,9 @@ describe('DsoEditMetadataComponent', () => { } }); } else { - it('should not exist', () => { + it('should not exist', waitForAsync(() => { expect(btn).toBeNull(); - }); + })); } }); } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts index 6dc5d4abc89..5590c71c065 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -13,7 +13,9 @@ import { ActivatedRoute, Data, } from '@angular/router'; +import { DATA_SERVICE_FACTORY } from '@dspace/core/cache/builders/build-decorators'; import { ArrayMoveChangeAnalyzer } from '@dspace/core/data/array-move-change-analyzer.service'; +import { HALDataService } from '@dspace/core/data/base/hal-data-service.interface'; import { RemoteData } from '@dspace/core/data/remote-data'; import { UpdateDataService } from '@dspace/core/data/update-data.service'; import { @@ -24,8 +26,12 @@ import { lazyDataService } from '@dspace/core/lazy-data-service'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { Context } from '@dspace/core/shared/context.model'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { GenericConstructor } from '@dspace/core/shared/generic-constructor'; +import { Item } from '@dspace/core/shared/item.model'; import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; import { ResourceType } from '@dspace/core/shared/resource-type'; +import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; +import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { hasNoValue, hasValue, @@ -37,6 +43,7 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, combineLatest as observableCombineLatest, Observable, of, @@ -44,7 +51,7 @@ import { } from 'rxjs'; import { map, - mergeMap, + switchMap, tap, } from 'rxjs/operators'; @@ -53,7 +60,10 @@ import { AlertType } from '../../shared/alert/alert-type'; import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata-field-values/dso-edit-metadata-field-values.component'; -import { DsoEditMetadataForm } from './dso-edit-metadata-form'; +import { + DsoEditMetadataChangeType, + DsoEditMetadataForm, +} from './dso-edit-metadata-form'; import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata-headers/dso-edit-metadata-headers.component'; import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value/dso-edit-metadata-value.component'; import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component'; @@ -153,6 +163,28 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { */ dsoUpdateSubscription: Subscription; + /** + * Field to keep track of the current security level + * in case a new mdField is added and the security level needs to be set + */ + newMdFieldWithSecurityLevelValue: number; + + /** + * Flag to indicate if the metadata security configuration is present + * for the newly added metadata field + */ + hasSecurityMetadata = false; + + /** + * Contains metadata security configuration object + */ + isFormInitialized$: BehaviorSubject = new BehaviorSubject(false); + + /** + * Contains metadata security configuration object + */ + securitySettings$: BehaviorSubject = new BehaviorSubject(null); + public readonly Context = Context; constructor(protected route: ActivatedRoute, @@ -161,7 +193,9 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { protected parentInjector: Injector, protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer, protected cdr: ChangeDetectorRef, - @Inject(APP_DATA_SERVICES_MAP) private dataServiceMap: LazyDataServicesMap) { + @Inject(APP_DATA_SERVICES_MAP) private dataServiceMap: LazyDataServicesMap, + protected metadataSecurityConfigurationService: MetadataSecurityConfigurationService, + @Inject(DATA_SERVICE_FACTORY) protected getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>) { } /** @@ -172,24 +206,47 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { if (hasNoValue(this.dso)) { this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe( map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)), - tap((data: any) => this.initDSO(data.dso.payload)), - mergeMap(() => this.retrieveDataService()), - ).subscribe((dataService: UpdateDataService) => { + tap((data: any) => this.initDSO(data.dso.payload)), + switchMap(() => combineLatest([this.retrieveDataService(),this.getSecuritySettings()])), + ).subscribe(([dataService, securitySettings]: [UpdateDataService, MetadataSecurityConfiguration]) => { + this.securitySettings$.next(securitySettings); this.initDataService(dataService); this.initForm(); + this.isFormInitialized$.next(true); }); } else { this.initDSOType(this.dso); - this.retrieveDataService().subscribe((dataService: UpdateDataService) => { - this.initDataService(dataService); - this.initForm(); - }); + observableCombineLatest([this.retrieveDataService(), this.getSecuritySettings()]) + .subscribe(([dataService, securitySettings]: [UpdateDataService, MetadataSecurityConfiguration]) => { + this.securitySettings$.next(securitySettings); + this.initDataService(dataService); + this.initForm(); + this.isFormInitialized$.next(true); + }); } this.savingOrLoadingFieldValidation$ = observableCombineLatest([this.saving$, this.loadingFieldValidation$]).pipe( map(([saving, loading]: [boolean, boolean]) => saving || loading), ); } + /** + * Get the security settings for the current DSpaceObject, + * based on entityType (e.g. Person) + */ + getSecuritySettings(): Observable { + if (this.dso instanceof Item) { + const entityType: string = (this.dso as Item).entityType; + return this.metadataSecurityConfigurationService.findById(entityType).pipe( + getFirstCompletedRemoteData(), + map((securitySettingsRD: RemoteData) => { + return securitySettingsRD.hasSucceeded ? securitySettingsRD.payload : null; + }), + ); + } else { + of(null); + } + } + /** * Resolve the data-service for the current DSpaceObject and retrieve its instance */ @@ -297,6 +354,7 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { this.loadingFieldValidation$.next(false); if (valid) { this.form.setMetadataField(this.newMdField); + this.setSecurityLevelForNewMdField(); this.onValueSaved(); } }); @@ -326,6 +384,74 @@ export class DsoEditMetadataComponent implements OnInit, OnDestroy { this.onValueSaved(); } + /** + * Keep track of the metadata field that is currently being edited / added + * Reset the security level properties for the new metadata field + * @param value The value of the new metadata field + */ + onMdFieldChange(value: string){ + if (hasValue(value)) { + this.newMdFieldWithSecurityLevelValue = null; + this.hasSecurityMetadata = false; + } + } + + /** + * Update the security level for the field at the given index + */ + onUpdateSecurityLevel(securityLevel: number) { + this.setSecurityLevelForNewMdField(securityLevel); + } + + /** + * Set the security level for the new metadata field + * If the new metadata field has no security level yet, store the security level in a temporary variable + * until the metadata field is validated and set. + * @param securityLevel The security level to set for the new metadata field + */ + setSecurityLevelForNewMdField(securityLevel?: number) { + // if the metadata field already exists among the metadata fields, + // set the security level for the new metadata field in the right position + if (hasValue(this.newMdField) && hasValue(this.form.fields[this.newMdField]) && this.hasSecurityMetadata) { + const lastIndex = this.form.fields[this.newMdField].length - 1; + const obj = this.form.fields[this.newMdField][lastIndex]; + + if (hasValue(securityLevel)) { + // metadata field is not set yet, so store the security level for the new metadata field + this.newMdFieldWithSecurityLevelValue = securityLevel; + } else { + // metadata field is set, so set the security level for the new metadata field + obj.change = DsoEditMetadataChangeType.ADD; + const customSecurity = this.securitySettings$.value.metadataCustomSecurity[this.newMdField]; + const lastCustomSecurityLevel = customSecurity[customSecurity.length - 1]; + + obj.newValue.securityLevel = this.newMdFieldWithSecurityLevelValue ?? lastCustomSecurityLevel; + } + } + + // if the security level value is changed before the metadata field is set, + // store the security level in a temporary variable + if (hasValue(securityLevel) && hasNoValue(this.form.fields[this.newMdField])) { + this.newMdFieldWithSecurityLevelValue = securityLevel; + } + + if (!this.hasSecurityMetadata) { + // for newly added metadata fields, set the security level to the default security level + // (in case there is no custom security level for the metadata field) + const defaultSecurity = this.securitySettings$.value.metadataSecurityDefault; + const lastDefaultSecurityLevel = defaultSecurity[defaultSecurity.length - 1]; + + this.form.fields[this.newMdField][this.form.fields[this.newMdField].length - 1].newValue.securityLevel = lastDefaultSecurityLevel; + } + } + + /** + * Check if the new metadata field has a security level + */ + hasSecurityLevel(event: boolean) { + this.hasSecurityMetadata = event; + } + /** * Unsubscribe from any open subscriptions */ diff --git a/src/app/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/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index e64cffd7bb8..efb92a35621 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -58,12 +58,19 @@
- + } @else { + - + + }
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 450350a6dab..bb8d79ca138 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -7,6 +7,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { GenericItemPageFieldComponent } from '../../../../item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../../../item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; +import { AuthorityRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component'; import { TabbedRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; @@ -22,6 +23,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail templateUrl: './person.component.html', imports: [ AsyncPipe, + AuthorityRelatedEntitiesSearchComponent, DsoEditMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, 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/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts index e9b825dc966..092cb6ef96d 100644 --- a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts +++ b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts @@ -11,6 +11,7 @@ import { import { By } from '@angular/platform-browser'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; import { isNotEmpty } from '@dspace/shared/utils/empty.util'; import { @@ -23,6 +24,10 @@ import { MetadataUriValuesComponent } from './metadata-uri-values.component'; let comp: MetadataUriValuesComponent; let fixture: ComponentFixture; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; + const mockMetadata = [ { @@ -38,6 +43,7 @@ const mockSeperator = '
'; const mockLabel = 'fake.message'; const mockLinkText = 'fake link text'; + describe('MetadataUriValuesComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -49,6 +55,7 @@ describe('MetadataUriValuesComponent', () => { }), MetadataUriValuesComponent], providers: [ { provide: APP_CONFIG, useValue: environment }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(MetadataUriValuesComponent, { diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.html b/src/app/item-page/field-components/metadata-values/metadata-values.component.html index 60fca0a8b71..7ed177f5b6d 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.html +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.html @@ -1,10 +1,11 @@ @for (mdValue of mdValues; track mdValue; let last = $last) { + @let isVocabulary = isControlledVocabulary(mdValue); - + @if (!last) { @@ -50,3 +51,14 @@ [routerLink]="['/browse', browseDefinition.id]" [queryParams]="getQueryParams(value)" role="link" tabindex="0">{{value}} + + + + + @let label = getVocabularyValue(value) | async; + @if (hasBrowseDefinition()) { + + } @else { + {{label}} + } + diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts index 21d5d528cbf..f3581f8cc8c 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts @@ -9,12 +9,17 @@ import { } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { buildPaginatedList } from '@dspace/core/data/paginated-list.model'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import { PageInfo } from '@dspace/core/shared/page-info.model'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { TranslateLoader, TranslateModule, } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { environment } from '../../../../environments/environment'; import { MetadataValuesComponent } from './metadata-values.component'; @@ -37,6 +42,18 @@ const mockMetadata = [ }] as MetadataValue[]; const mockSeperator = '
'; const mockLabel = 'fake.message'; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; + +const controlledMetadata = { + value: 'Original Value', + authority: 'srsc:1234', + uuid: 'metadata-uuid-1', + language: 'en_US', + place: null, + confidence: 600, +} as MetadataValue; describe('MetadataValuesComponent', () => { beforeEach(waitForAsync(() => { @@ -49,6 +66,7 @@ describe('MetadataValuesComponent', () => { }), MetadataValuesComponent], providers: [ { provide: APP_CONFIG, useValue: environment }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(MetadataValuesComponent, { @@ -99,4 +117,29 @@ describe('MetadataValuesComponent', () => { expect(result.rel).toBe('noopener noreferrer'); }); + it('should detect controlled vocabulary metadata', () => { + const result = comp.isControlledVocabulary(controlledMetadata); + expect(result).toBeTrue(); + }); + + it('should return translated vocabulary value when available', (done) => { + const vocabEntry = { + display: 'Translated Value', + }; + + vocabularyServiceMock.getPublicVocabularyEntryByID.and.returnValue( + of( + createSuccessfulRemoteDataObject( + buildPaginatedList(new PageInfo(), [vocabEntry]), + ), + ), + ); + + comp.getVocabularyValue(controlledMetadata).subscribe((value) => { + expect(value).toBe('Translated Value'); + done(); + }); + }); + + }); diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index 666ece3f400..c37f1f975bf 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -1,4 +1,7 @@ -import { NgTemplateOutlet } from '@angular/common'; +import { + AsyncPipe, + NgTemplateOutlet, +} from '@angular/common'; import { Component, Inject, @@ -13,9 +16,20 @@ import { } from '@dspace/config/app-config.interface'; import { BrowseDefinition } from '@dspace/core/shared/browse-definition.model'; import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import { + getFirstCompletedRemoteData, + getPaginatedListPayload, + getRemoteDataPayload, +} from '@dspace/core/shared/operators'; import { VALUE_LIST_BROWSE_DEFINITION } from '@dspace/core/shared/value-list-browse-definition.resource-type'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { hasValue } from '@dspace/shared/utils/empty.util'; import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; import { MetadataFieldWrapperComponent } from '../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; @@ -31,6 +45,7 @@ import { ImageField } from '../../simple/field-components/specific-field/image-f styleUrls: ['./metadata-values.component.scss'], templateUrl: './metadata-values.component.html', imports: [ + AsyncPipe, MarkdownDirective, MetadataFieldWrapperComponent, NgTemplateOutlet, @@ -41,6 +56,7 @@ import { ImageField } from '../../simple/field-components/specific-field/image-f export class MetadataValuesComponent implements OnChanges { constructor( + protected vocabularyService: VocabularyService, @Inject(APP_CONFIG) private appConfig: AppConfig, ) { } @@ -110,6 +126,30 @@ export class MetadataValuesComponent implements OnChanges { return false; } + /** + * Whether the metadata is a controlled vocabulary + * @param value A MetadataValue being displayed + */ + isControlledVocabulary(metadataValue: MetadataValue): boolean { + const vocabularyId = this.getVocabularyIdFromAuthorityValue(metadataValue); + return hasValue(this.getVocabularyName(vocabularyId)); + } + + /** + * Return configured vocabulary name for this metadata value + */ + getVocabularyName(vocabularyId: string): string | null { + return this.appConfig.vocabularies.find(vocabulary => vocabulary.vocabulary === vocabularyId)?.vocabulary; + } + + /** + * Get value from authority for vocabulary lookup + */ + getVocabularyIdFromAuthorityValue(metadataValue: MetadataValue): string { + const authority = metadataValue.authority ? metadataValue.authority.split(':') : undefined; + return authority?.length > 1 ? authority[0] : null; + } + /** * Return a queryparams object for use in a link, with the key dependent on whether this browse * definition is metadata browse, or item browse @@ -146,4 +186,21 @@ export class MetadataValuesComponent implements OnChanges { return { target: '_blank', rel: 'noopener noreferrer' }; } } + + /** + * Get vocabulary translated value from metadata value + */ + getVocabularyValue(metadataValue: MetadataValue): Observable { + const vocabularyId = this.getVocabularyIdFromAuthorityValue(metadataValue); + const vocabularyName = this.getVocabularyName(vocabularyId); + + return this.vocabularyService.getPublicVocabularyEntryByID(vocabularyName, metadataValue.authority.split(':')[1]).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + getPaginatedListPayload(), + map((res) => res?.length > 0 ? res[0] : null), + map((res) => res?.display ?? metadataValue.value), + take(1), + ); + } } diff --git a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts index 4243accfdbb..9b2ce62d6c8 100644 --- a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts @@ -11,6 +11,7 @@ import { By } from '@angular/platform-browser'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { BrowseService } from '@dspace/core/browse/browse.service'; import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definition-data.service'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; @@ -24,6 +25,9 @@ import { ItemPageAbstractFieldComponent } from './item-page-abstract-field.compo let comp: ItemPageAbstractFieldComponent; let fixture: ComponentFixture; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; describe('ItemPageAbstractFieldComponent', () => { beforeEach(waitForAsync(() => { @@ -41,6 +45,7 @@ describe('ItemPageAbstractFieldComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageAbstractFieldComponent, { diff --git a/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts index 247fce3569e..f73aa4bee61 100644 --- a/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts @@ -11,6 +11,7 @@ import { ActivatedRoute } from '@angular/router'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { BrowseService } from '@dspace/core/browse/browse.service'; import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definition-data.service'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; @@ -30,6 +31,9 @@ let fixture: ComponentFixture; const mockFields = ['dc.contributor.author', 'dc.creator', 'dc.contributor']; const mockValue = 'test value'; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; describe('ItemPageAuthorFieldComponent', () => { beforeEach(waitForAsync(() => { @@ -45,6 +49,7 @@ describe('ItemPageAuthorFieldComponent', () => { { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageAuthorFieldComponent, { diff --git a/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts index c047be679f9..22c0080eaa5 100644 --- a/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts @@ -11,6 +11,7 @@ import { ActivatedRoute } from '@angular/router'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { BrowseService } from '@dspace/core/browse/browse.service'; import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definition-data.service'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; @@ -28,8 +29,12 @@ import { ItemPageDateFieldComponent } from './item-page-date-field.component'; let comp: ItemPageDateFieldComponent; let fixture: ComponentFixture; + const mockField = 'dc.date.issued'; const mockValue = 'test value'; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; describe('ItemPageDateFieldComponent', () => { beforeEach(waitForAsync(() => { @@ -45,6 +50,7 @@ describe('ItemPageDateFieldComponent', () => { { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageDateFieldComponent, { diff --git a/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts index c6d26a2b363..0b5890631ab 100644 --- a/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts @@ -11,6 +11,7 @@ import { ActivatedRoute } from '@angular/router'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { BrowseService } from '@dspace/core/browse/browse.service'; import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definition-data.service'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; @@ -32,6 +33,10 @@ const mockValue = 'test value'; const mockField = 'dc.test'; const mockLabel = 'test label'; const mockFields = [mockField]; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; + describe('GenericItemPageFieldComponent', () => { beforeEach(waitForAsync(() => { @@ -47,6 +52,7 @@ describe('GenericItemPageFieldComponent', () => { { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(GenericItemPageFieldComponent, { diff --git a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts index 5c8a7505219..9a2fc201ddf 100644 --- a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts @@ -10,6 +10,7 @@ import { By } from '@angular/platform-browser'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { BrowseService } from '@dspace/core/browse/browse.service'; import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definition-data.service'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; @@ -37,6 +38,10 @@ const mockImg = { alt: 'item.page.image.alt.ROR', heightVar: '--ds-item-page-img-field-ror-inline-height', } as ImageField; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; + describe('ItemPageImgFieldComponent', () => { @@ -52,6 +57,7 @@ describe('ItemPageImgFieldComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }) diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts index 8dac865034d..f1a7fb95726 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts @@ -18,6 +18,7 @@ import { MetadataMap, MetadataValue, } from '@dspace/core/shared/metadata.models'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; @@ -43,6 +44,9 @@ const mockLabel = 'test label'; const mockAuthorField = 'dc.contributor.author'; const mockDateIssuedField = 'dc.date.issued'; const mockFields = [mockField, mockAuthorField, mockDateIssuedField]; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; describe('ItemPageFieldComponent', () => { @@ -70,6 +74,7 @@ describe('ItemPageFieldComponent', () => { { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, { provide: MathService, useValue: {} }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageFieldComponent, { diff --git a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts index 9250f74c3a0..0c53723fa57 100644 --- a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -10,6 +10,7 @@ import { import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { BrowseService } from '@dspace/core/browse/browse.service'; import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definition-data.service'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; @@ -29,6 +30,10 @@ let fixture: ComponentFixture; const mockField = 'dc.identifier.uri'; const mockValue = 'test value'; const mockLabel = 'test label'; +const vocabularyServiceMock = { + getPublicVocabularyEntryByID: jasmine.createSpy('getPublicVocabularyEntryByID'), +}; + describe('ItemPageUriFieldComponent', () => { beforeEach(waitForAsync(() => { @@ -43,6 +48,7 @@ describe('ItemPageUriFieldComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, + { provide: VocabularyService, useValue: vocabularyServiceMock }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageUriFieldComponent, { diff --git a/src/app/item-page/simple/item-types/shared/item.component.ts b/src/app/item-page/simple/item-types/shared/item.component.ts index 2c4c50eb532..965a18ef199 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.ts @@ -79,10 +79,16 @@ export class ItemComponent implements OnInit { */ geospatialItemPageFieldsEnabled = false; + /** + * Flag to check whether to use the default relations or the authority based ones + */ + areAuthorityRelationsEnabled: boolean; + constructor(protected routeService: RouteService, protected router: Router) { this.mediaViewer = environment.mediaViewer; this.geospatialItemPageFieldsEnabled = environment.geospatialMapViewer.enableItemPageFields; + this.areAuthorityRelationsEnabled = environment.item.showAuthorithyRelations; } /** diff --git a/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.html b/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.html new file mode 100644 index 00000000000..85e15cb6fbc --- /dev/null +++ b/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.html @@ -0,0 +1,41 @@ +@if (configurations.length > 1) { + + +
+} + + +@if (configurations.length === 1) { + + +} diff --git a/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.spec.ts b/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.spec.ts new file mode 100644 index 00000000000..acb3ecde6e9 --- /dev/null +++ b/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.spec.ts @@ -0,0 +1,99 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { Item } from '@dspace/core/shared/item.model'; +import { RouterMock } from '@dspace/core/testing/router.mock'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { ThemedConfigurationSearchPageComponent } from '../../../../search-page/themed-configuration-search-page.component'; +import { AuthorityRelatedEntitiesSearchComponent } from './authority-related-entities-search.component'; + + +describe('AuthorityRelatedEntitiesSearchComponent', () => { + let component: AuthorityRelatedEntitiesSearchComponent; + let fixture: ComponentFixture; + + const mockItem = { + id: 'test-id-123', + } as Item; + const router = new RouterMock(); + + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), AuthorityRelatedEntitiesSearchComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParams: of({ tab: 'relations-configuration' }), + snapshot: { + queryParams: { + scope: 'collection-uuid', + query: 'test', + }, + }, + }, + }, + { provide: Router, useValue: router }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AuthorityRelatedEntitiesSearchComponent, { + remove: { + imports: [ + ThemedConfigurationSearchPageComponent, + ], + }, + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuthorityRelatedEntitiesSearchComponent); + component = fixture.componentInstance; + component.item = mockItem; + component.configurations = ['relations-configuration']; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set searchFilter on init', () => { + component.item = mockItem; + component.ngOnInit(); + + expect(component.searchFilter).toBe('scope=test-id-123'); + }); + + it('should render configuration search page when configuration is provided', () => { + component.item = mockItem; + component.configurations = ['test-config']; + + fixture.detectChanges(); + + const searchPage = fixture.nativeElement.querySelector('ds-configuration-search-page'); + expect(searchPage).toBeTruthy(); + }); + + it('should NOT render configuration search page when configuration is missing', () => { + component.item = mockItem; + component.configurations = []; + + fixture.detectChanges(); + + const searchPage = fixture.nativeElement.querySelector('ds-configuration-search-page'); + expect(searchPage).toBeFalsy(); + }); + +}); diff --git a/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.ts b/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.ts new file mode 100644 index 00000000000..9450edb9ba3 --- /dev/null +++ b/src/app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component.ts @@ -0,0 +1,52 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { + NgbNav, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavOutlet, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ThemedConfigurationSearchPageComponent } from '../../../../search-page/themed-configuration-search-page.component'; +import { TabbedRelatedEntitiesSearchComponent } from '../tabbed-related-entities-search/tabbed-related-entities-search.component'; + +@Component({ + selector: 'ds-authority-related-entities-search', + templateUrl: './authority-related-entities-search.component.html', + imports: [ + AsyncPipe, + NgbNav, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavOutlet, + ThemedConfigurationSearchPageComponent, + TranslateModule, + ], +}) +/** + * A component to show related items as search results, based on authority value + */ +export class AuthorityRelatedEntitiesSearchComponent extends TabbedRelatedEntitiesSearchComponent implements OnInit { + /** + * Filter used for set scope in discovery invocation + */ + searchFilter: string; + /** + * Discovery configurations for search page + */ + @Input() configurations: string[] = []; + + + + ngOnInit() { + super.ngOnInit(); + this.searchFilter = `scope=${this.item.id}`; + } +} diff --git a/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html b/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html index 147650b11aa..901077dbfb1 100644 --- a/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html +++ b/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html @@ -1,6 +1,6 @@ @if (relationTypes.length > 1) {