diff --git a/config/config.example.yml b/config/config.example.yml index 6fdfa94add5..645ca350053 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 @@ -641,3 +647,91 @@ 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 + +# 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 diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index 6b3881b3b82..7e06f7530b7 100644 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -37,14 +37,11 @@ function parseCliInput() { .option('-o, --output-file ', 'where output of script ends up; mutually exclusive with -i') .usage('([-d ] [-s ]) || (-t (-i | -o ) [-s ])') .parse(process.argv); - - const sourceFile = program.opts().sourceFile; - - if (!program.targetFile) { + if (!program.targetFile) { fs.readdirSync(projectRoot(LANGUAGE_FILES_LOCATION)).forEach(file => { - if (!sourceFile.toString().endsWith(file)) { + if (program.opts().sourceFile && !program.opts().sourceFile.toString().endsWith(file)) { const targetFileLocation = projectRoot(LANGUAGE_FILES_LOCATION + "/" + file); - console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + sourceFile); + console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + program.opts().sourceFile); if (program.outputDir) { if (!fs.existsSync(program.outputDir)) { fs.mkdirSync(program.outputDir); @@ -69,7 +66,7 @@ function parseCliInput() { console.log(program.outputHelp()); process.exit(1); } - if (!checkIfFileExists(sourceFile)) { + if (!checkIfFileExists(program.opts().sourceFile)) { console.error('Path of source file is not valid.'); console.log(program.outputHelp()); process.exit(1); 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/core/data/external-source-data.service.ts b/src/app/core/data/external-source-data.service.ts index 64a6fac147d..23b3d75eee0 100644 --- a/src/app/core/data/external-source-data.service.ts +++ b/src/app/core/data/external-source-data.service.ts @@ -66,6 +66,17 @@ export class ExternalSourceDataService extends IdentifiableDataService { + return this.getBrowseEndpoint().pipe( + map((href) => href + '/' + externalSourceId + '/entryValues/' + entryId), + ); + } + /** * Get the entries for an external source * @param externalSourceId The id of the external source to fetch entries for 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..34013789756 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,10 @@ export class JsonPatchOperationsBuilder { * the value to update the referenced path * @param plain * a boolean representing if the value to be added is a plain text value + * @param securityLevel + * @param language */ - replace(path: JsonPatchOperationPathObject, value, plain = false) { + replace(path: JsonPatchOperationPathObject, value, plain = false, language = null) { if (hasNoValue(value) || (typeof value === 'object' && hasNoValue(value.value))) { this.remove(path); } else { @@ -73,7 +76,7 @@ export class JsonPatchOperationsBuilder { path.rootElement, path.subRootElement, path.path, - this.prepareValue(value, plain, false))); + this.prepareValue(value, plain, false, language))); } } @@ -124,7 +127,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..1aaa8e0bd7a 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 { @@ -48,7 +49,7 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { this.display = display || value; 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; @@ -68,7 +69,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); } /** 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..bb08651433c 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. */ @@ -55,7 +56,6 @@ export class MetadataValue implements MetadataValueInterface { /** The authority confidence value */ @autoserialize confidence: number; - } /** Constraints for matching metadata values. */ @@ -74,6 +74,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 { 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/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/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..d871cdb9b8a 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 @@ -48,6 +48,15 @@
{{ mdValue.newValue.language }}
} +
+ @if (!mdValue.editing) { +
{{ mdValue.newValue.authority }}
+ } + @if(mdValue.editing) { + + } +
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..4a80f28fde6 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,7 +8,7 @@ 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 { @@ -74,7 +74,7 @@ describe('DsoEditMetadataValueComponent', () => { await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), - RouterModule.forRoot([]), + RouterTestingModule.withRoutes([]), DsoEditMetadataValueComponent, VarDirective, BtnDisabledDirective, @@ -89,8 +89,8 @@ describe('DsoEditMetadataValueComponent', () => { .overrideComponent(DsoEditMetadataValueComponent, { remove: { imports: [ - DsoEditMetadataValueFieldLoaderComponent, ThemedTypeBadgeComponent, + DsoEditMetadataValueFieldLoaderComponent, ], }, }) @@ -101,6 +101,7 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; + component.mdField = 'person.birthDate'; component.saving$ = of(false); fixture.detectChanges(); }); 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..66536c3f9b2 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,6 +7,7 @@ import { NgClass, } from '@angular/common'; import { + ChangeDetectorRef, Component, EventEmitter, Input, @@ -32,8 +33,12 @@ import { import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; import { hasValue } 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, EMPTY, Observable, } from 'rxjs'; @@ -83,16 +88,30 @@ 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 field to display a value for */ - @Input() mdField: string; + @Input() + set mdField(mdField: string) { + this._mdField$.next(mdField); + } + + get mdField() { + return this._mdField$.value; + } + + protected readonly _mdField$ = new BehaviorSubject(null); /** - * Editable metadata value to show + * Flag whether this is a new metadata field or exists already */ - @Input() mdValue: DsoEditMetadataValue; + @Input() isNewMdField = false; /** * Type of DSO we're displaying values for @@ -169,6 +188,8 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { protected relationshipService: RelationshipDataService, protected dsoNameService: DSONameService, protected metadataService: MetadataService, + protected cdr: ChangeDetectorRef, + protected translate: TranslateService, protected dsoEditMetadataFieldService: DsoEditMetadataFieldService, ) { } @@ -177,11 +198,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges { this.initVirtualProperties(); } - ngOnChanges(changes: SimpleChanges): void { - if (changes.mdField) { - this.fieldType$ = this.getFieldType(); - } - } /** * Initialise potential properties of a virtual metadata value @@ -219,4 +235,15 @@ 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 (changes.mdField) { + this.fieldType$ = this.getFieldType(); + } + } } 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/shared/entity-icon/entity-icon.directive.spec.ts b/src/app/shared/entity-icon/entity-icon.directive.spec.ts new file mode 100644 index 00000000000..4322f99e146 --- /dev/null +++ b/src/app/shared/entity-icon/entity-icon.directive.spec.ts @@ -0,0 +1,131 @@ +import { Component } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; + +import { EntityIconDirective } from './entity-icon.directive'; + +describe('EntityIconDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + EntityIconDirective, + TestComponent, + ], + }).compileComponents(); + }); + + describe('with default value provided', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display a text-success icon', () => { + const successIcon = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]').querySelector('i.text-success'); + expect(successIcon).toBeTruthy(); + }); + + it('should display a text-success icon after span', () => { + const successIcon = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]').querySelector('i.text-success'); + const entityElement = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]'); + // position 1 because the icon is after the span + expect(entityElement.children[1]).toBe(successIcon); + }); + }); + + describe('with primary value provided', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + component.metadata.entityType = 'person'; + component.metadata.entityStyle = 'personStaff'; + component.iconPosition = 'before'; + fixture.detectChanges(); + }); + + it('should display a text-primary icon', () => { + const primaryIcon = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]').querySelector('i.text-primary'); + expect(primaryIcon).toBeTruthy(); + }); + + it('should display a text-primary icon before span', () => { + const primaryIcon = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]').querySelector('i.text-primary'); + const entityElement = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]'); + // position 0 because the icon is before the span + expect(entityElement.children[0]).toBe(primaryIcon); + }); + }); + + describe('when given type doesn\'t exist and fallback on default disabled', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + component.fallbackOnDefault = false; + component.metadata.entityType = 'TESTFAKE'; + component.metadata.entityStyle = 'personFallback'; + component.iconPosition = 'before'; + fixture.detectChanges(); + }); + + it('should not display a text-primary icon', () => { + const primaryIcon = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]').querySelector('i'); + expect(primaryIcon).toBeFalsy(); + }); + }); + + describe('when given style doesn\'t exist and fallback on default disabled', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + component.fallbackOnDefault = false; + component.metadata.entityType = 'person'; + component.metadata.entityStyle = 'personFallback'; + component.iconPosition = 'before'; + fixture.detectChanges(); + }); + + it('should not display a text-primary icon', () => { + const primaryIcon = fixture.nativeElement.querySelector('[data-test="entityTestComponent"]').querySelector('i'); + expect(primaryIcon).toBeFalsy(); + }); + }); + +}); + + // declare a test component + @Component({ + selector: 'ds-test-cmp', + template: ` +
+ {{ metadata.value }}
`, + imports: [ + EntityIconDirective, + ], + }) +class TestComponent { + + metadata = { + authority: null, + value: 'Test', + orcidAuthenticated: null, + entityType: 'default', + entityStyle: 'default', + }; + iconPosition = 'after'; + fallbackOnDefault = true; + + } diff --git a/src/app/shared/entity-icon/entity-icon.directive.ts b/src/app/shared/entity-icon/entity-icon.directive.ts new file mode 100644 index 00000000000..f06de65fd37 --- /dev/null +++ b/src/app/shared/entity-icon/entity-icon.directive.ts @@ -0,0 +1,130 @@ +import { + Directive, + ElementRef, + Input, + OnInit, +} from '@angular/core'; +import { + AuthorityRefConfig, + AuthorityRefEntityStyleConfig, +} from '@dspace/config/layout-config.interfaces'; +import { + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; + +import { environment } from '../../../environments/environment'; + + +/** + * Directive to add to the element a entity icon based on metadata entity type and entity style + */ +@Directive({ + selector: '[dsEntityIcon]', + standalone: true, +}) +export class EntityIconDirective implements OnInit { + + /** + * The metadata entity type + */ + @Input() entityType = 'default'; + + /** + * The metadata entity style + */ + @Input() entityStyle: string|string[] = 'default'; + + /** + * A boolean representing if to fallback on default style if the given one is not found + */ + @Input() fallbackOnDefault = true; + + /** + * A boolean representing if to show html icon before or after + */ + @Input() iconPosition = 'after'; + + /** + * A configuration representing authorityRef values + */ + confValue = environment.layout.authorityRef; + + /** + * Initialize instance variables + * + * @param {ElementRef} elem + */ + constructor(private elem: ElementRef) { + } + + /** + * Adding icon to element oninit + */ + ngOnInit() { + const crisRefConfig: AuthorityRefConfig = this.getCrisRefConfigByType(this.entityType); + if (isNotEmpty(crisRefConfig)) { + const crisStyle: AuthorityRefEntityStyleConfig = this.getCrisRefEntityStyleConfig(crisRefConfig, this.entityStyle); + if (isNotEmpty(crisStyle)) { + this.addIcon(crisStyle); + } + } + } + + /** + * Return the AuthorityRefConfig by the given type + * + * @param type + * @private + */ + private getCrisRefConfigByType(type: string): AuthorityRefConfig { + let filteredConf: AuthorityRefConfig = this.confValue.find((config) => config.entityType.toUpperCase() === type.toUpperCase()); + if (isEmpty(filteredConf) && this.fallbackOnDefault) { + filteredConf = this.confValue.find((config) => config.entityType.toUpperCase() === 'DEFAULT'); + } + + return filteredConf; + } + + /** + * Return the AuthorityRefEntityStyleConfig by the given style + * + * @param crisConfig + * @param styles + * @private + */ + private getCrisRefEntityStyleConfig(crisConfig: AuthorityRefConfig, styles: string|string[]): AuthorityRefEntityStyleConfig { + let filteredConf: AuthorityRefEntityStyleConfig; + if (Array.isArray(styles)) { + styles.forEach((style) => { + if (Object.keys(crisConfig.entityStyle).includes(style)) { + filteredConf = crisConfig.entityStyle[style]; + } + }); + } else { + filteredConf = crisConfig.entityStyle[styles]; + } + + if (isEmpty(filteredConf) && this.fallbackOnDefault) { + filteredConf = crisConfig.entityStyle.default; + } + + return filteredConf; + } + + /** + * Attach icon to HTML element + * + * @param crisStyle + * @private + */ + private addIcon(entityStyle: AuthorityRefEntityStyleConfig): void { + const iconElement = ``; + if (this.iconPosition === 'after') { + this.elem.nativeElement.insertAdjacentHTML('afterend', ' ' + iconElement); + } else { + this.elem.nativeElement.insertAdjacentHTML('beforebegin', iconElement + ' '); + } + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 60434caac87..7129667aacb 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -118,6 +118,8 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { const vocabularyOptions: VocabularyOptions = { name: 'type_programme', + metadata: null, + scope: null, closed: false, }; const formModel = [ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts index f249016c022..79089d0653e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -6,12 +6,21 @@ import { } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; +import { Metadata } from '@dspace/core/shared/metadata.utils'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; import { PageInfo } from '@dspace/core/shared/page-info.model'; +import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; -import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { + hasValue, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormControlComponent, + DynamicFormControlCustomEvent, + DynamicFormControlModel, DynamicFormLayoutService, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; @@ -19,10 +28,17 @@ import { Observable, of, } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + distinctUntilChanged, + map, + tap, +} from 'rxjs/operators'; +import { SubmissionService } from '../../../../../submission/submission.service'; +import { FormBuilderService } from '../../form-builder.service'; import { DsDynamicInputModel } from './ds-dynamic-input.model'; + /** * An abstract class to be extended by form components that handle vocabulary */ @@ -38,12 +54,32 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom @Output() abstract blur: EventEmitter; @Output() abstract change: EventEmitter; @Output() abstract focus: EventEmitter; + @Output() abstract customEvent: EventEmitter; + /** + * The vocabulary entry + */ + public vocabulary$: Observable = of(null); + + /** + * The PageInfo object + */ public abstract pageInfo: PageInfo; + protected otherInfoValue: string; + protected otherName: string; + protected otherInfoKey: string; + public otherInfoValues: string[] = []; + public otherInfoValuesUnformatted: string[] = []; + + multiValueOnGenerator: boolean; + protected constructor(protected vocabularyService: VocabularyService, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, + protected formBuilderService: FormBuilderService, + protected modalService: NgbModal, + protected submissionService: SubmissionService, ) { super(layoutService, validationService); } @@ -63,7 +99,7 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom let initValue$: Observable; if (isNotEmpty(this.model.value) && (this.model.value instanceof FormFieldMetadataValueObject) && !this.model.value.hasAuthorityToGenerate()) { let initEntry$: Observable; - if (this.model.value.hasAuthority()) { + if (this.hasValidAuthority(this.model.value)) { initEntry$ = this.vocabularyService.getVocabularyEntryByID(this.model.value.authority, this.model.vocabularyOptions); } else { initEntry$ = this.vocabularyService.getVocabularyEntryByValue(this.model.value.value, this.model.vocabularyOptions); @@ -77,7 +113,7 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom initEntry.authority, initEntry.display, (this.model.value as any).place, - null, + (this.model.value as any).confidence || null, initEntry.otherInformation || null, ); // Preserve the original confidence @@ -97,7 +133,7 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom this.model.value.authority, this.model.value.display, 0, - null, + (this.model.value as any).confidence || null, this.model.value.otherInformation || null, ), ); @@ -107,6 +143,27 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom return initValue$; } + + hasAuthorityValue(): boolean { + return (hasValue(this.model.value) && (this.model.value instanceof FormFieldMetadataValueObject || this.model.value instanceof VocabularyEntry)) + ? this.model.value.hasAuthority() : false; + } + + + /** + * Retrieve vocabulary object + */ + initVocabulary(): void { + if (this.model.value) { + this.setCurrentValue(this.model.value, true); + } + this.vocabulary$ = this.vocabularyService.findVocabularyById(this.model.vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + distinctUntilChanged(), + tap((vocabulary: Vocabulary) => this.multiValueOnGenerator = vocabulary.multiValueOnGenerator), + ); + } + /** * Emits a blur event containing a given value. * @param event The value to emit. @@ -130,6 +187,22 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom dispatchUpdate(updateValue: any) { this.model.value = updateValue; this.change.emit(updateValue); + this.updateOtherInformation(updateValue); + } + + /** + * Update the model authority value. + * @param authority + */ + updateAuthority(authority: string) { + const currentValue: string = (this.model.value instanceof FormFieldMetadataValueObject + || this.model.value instanceof VocabularyEntry) ? this.model.value.value : this.model.value; + const valueWithAuthority: any = new FormFieldMetadataValueObject(currentValue, null, authority); + this.model.value = valueWithAuthority; + this.change.emit(valueWithAuthority); + setTimeout(() => { + this.submissionService.dispatchSave(this.model.submissionId); + }, 100); } /** @@ -147,4 +220,92 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom totalPages: totalPages, }); } + + /** + * When the model was update check if new value contains otherInformation property and + * try to populate related fields + * @param value + */ + updateOtherInformation(value: any) { + if (hasValue(value) && + (value instanceof VocabularyEntry || value instanceof FormFieldMetadataValueObject)) { + const otherInformation = value.otherInformation; + if (hasValue(otherInformation)) { + const updatedModels = []; + for (const key in otherInformation) { + if (otherInformation.hasOwnProperty(key) && key.startsWith('data-')) { + const fieldId = key.replace('data-', ''); + const newValues: FormFieldMetadataValueObject[] = this.getOtherInformationValue(otherInformation[key], key); + if (isNotEmpty(newValues)) { + newValues.forEach((newValue) => { + const updatedModel = this.formBuilderService.updateModelValue(fieldId, newValue); + if (isNotEmpty(updatedModel)) { + updatedModels.push(updatedModel); + } + }); + } + } + } + this.createChangeEventOnUpdate(updatedModels); + } + } + } + + protected createChangeEventOnUpdate(models: DynamicFormControlModel[]) { + if (models.length > 0) { + this.onCustomEvent({ updatedModels: models }, 'authorityEnrichment'); + } + } + + getOtherInformationValue(value: string, key: string): FormFieldMetadataValueObject[] { + if (!isNotEmpty(value) || key === 'alternative-names' ) { + return null; + } + + const returnValue = []; + if (value.indexOf('|||') === -1) { + returnValue.push(this.generateFormField(value)); + } else if (value.indexOf('|||') !== -1 && this.otherInfoValue) { + const otherValues: string[] = value.split('|||'); + if (this.multiValueOnGenerator) { + otherValues.forEach((tmpValue) => returnValue.push(this.generateFormField(tmpValue))); + } else { + const unformattedValue = this.otherInfoValuesUnformatted.find(otherInfoValue => otherInfoValue.includes(this.otherInfoValue || this.otherName)); + const authorityValue = hasValue(unformattedValue) ? unformattedValue.substring(unformattedValue.lastIndexOf('::') + 2) : null; + const otherInfo = {}; + let alternativeValue: string; + otherInfo[key] = value; + if (hasValue(this.otherName)) { + alternativeValue = otherValues[0].substring(0, otherValues[0].lastIndexOf('::')); + } + returnValue.push(new FormFieldMetadataValueObject( + hasValue(alternativeValue) ? alternativeValue : this.otherInfoValue, + null, + null, + authorityValue, + null, + null, + otherInfo, + )); + } + } + return returnValue; + } + + private generateFormField(value: string): FormFieldMetadataValueObject { + if (value.indexOf('::') === -1) { + return new FormFieldMetadataValueObject(value); + } else { + return new FormFieldMetadataValueObject( + value.substring(0, value.lastIndexOf('::')), + null, + null, + value.substring(value.lastIndexOf('::') + 2), + ); + } + } + + private hasValidAuthority(formMetadataValue: FormFieldMetadataValueObject) { + return Metadata.hasValidAuthority(formMetadataValue?.authority); + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index 47ffac1531b..603e226a1a9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -19,7 +19,9 @@ import { UntypedFormGroup, } from '@angular/forms'; import { By } from '@angular/platform-browser'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; +import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyOptions } from '@dspace/core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; @@ -27,21 +29,27 @@ import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '@dspace/core/testing/dynamic-form-mock-services'; +import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; import { createTestComponent } 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 { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { of } from 'rxjs'; +import { SubmissionService } from 'src/app/submission/submission.service'; +import { v4 as uuidv4 } from 'uuid'; import { BtnDisabledDirective } from '../../../../../btn-disabled.directive'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive'; +import { FormBuilderService } from '../../../form-builder.service'; import { DsDynamicLookupComponent } from './dynamic-lookup.component'; import { DynamicLookupModel, @@ -97,6 +105,23 @@ let LOOKUP_TEST_GROUP = new UntypedFormGroup({ lookup: new UntypedFormControl(), lookupName: new UntypedFormControl(), }); +const vocabulary = Object.assign(new Vocabulary(), { + id: 'vocabulary', + name: 'vocabulary', + scrollable: true, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + url: 'self', + }, + entries: { + url: 'entries', + }, + }, +}); +const validAuthority = uuidv4(); describe('Dynamic Lookup component', () => { function init() { @@ -181,6 +206,10 @@ describe('Dynamic Lookup component', () => { { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService }, { provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService }, + { provide: FormBuilderService }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + provideMockStore(), + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }); @@ -218,7 +247,7 @@ describe('Dynamic Lookup component', () => { describe('', () => { beforeEach(() => { - + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; @@ -238,6 +267,7 @@ describe('Dynamic Lookup component', () => { describe('and init model value is empty', () => { beforeEach(() => { + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; @@ -323,7 +353,7 @@ describe('Dynamic Lookup component', () => { describe('and init model value is not empty', () => { beforeEach(() => { - + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; @@ -334,7 +364,7 @@ describe('Dynamic Lookup component', () => { display: 'testDisplay', })); spyOn((lookupComp as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); - (lookupComp.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); + (lookupComp.model as any).value = new FormFieldMetadataValueObject('test', null,null, 'testDisplay'); lookupFixture.detectChanges(); // spyOn(store, 'dispatch'); @@ -366,18 +396,18 @@ describe('Dynamic Lookup component', () => { }); describe('and init model value is not empty with authority', () => { beforeEach(() => { - + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); const entry = of(Object.assign(new VocabularyEntry(), { - authority: 'test001', + authority: validAuthority, value: 'test', display: 'testDisplay', })); spyOn((lookupComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); - lookupComp.model.value = new FormFieldMetadataValueObject('test', null, 'test001', 'testDisplay'); + lookupComp.model.value = new FormFieldMetadataValueObject('test', null, validAuthority, 'testDisplay'); lookupFixture.detectChanges(); // spyOn(store, 'dispatch'); @@ -413,7 +443,7 @@ describe('Dynamic Lookup component', () => { describe('', () => { beforeEach(() => { - + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; @@ -445,7 +475,7 @@ describe('Dynamic Lookup component', () => { describe('and init model value is empty', () => { beforeEach(() => { - + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; @@ -498,7 +528,7 @@ describe('Dynamic Lookup component', () => { describe('and init model value is not empty', () => { beforeEach(() => { - + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; @@ -543,19 +573,19 @@ describe('Dynamic Lookup component', () => { describe('and init model value is not empty with authority', () => { beforeEach(() => { - + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); - lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, 'test001'); + lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null,null, validAuthority); const entry = of(Object.assign(new VocabularyEntry(), { - authority: 'test001', + authority: validAuthority, value: 'Name, Lastname', display: 'Name, Lastname', })); spyOn((lookupComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); - lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, 'test001', 'Name, Lastname'); + lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, validAuthority, 'Name, Lastname'); lookupFixture.detectChanges(); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts index 2a7a4427d51..3b78a9168a2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts @@ -33,9 +33,11 @@ import { import { NgbDropdown, NgbDropdownModule, + NgbModal, NgbTooltip, } from '@ng-bootstrap/ng-bootstrap'; import { + DynamicFormControlCustomEvent, DynamicFormLayoutService, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; @@ -50,9 +52,11 @@ import { distinctUntilChanged, } from 'rxjs/operators'; +import { SubmissionService } from '../../../../../../submission/submission.service'; import { BtnDisabledDirective } from '../../../../../btn-disabled.directive'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive'; +import { FormBuilderService } from '../../../form-builder.service'; import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; @@ -84,6 +88,7 @@ export class DsDynamicLookupComponent extends DsDynamicVocabularyComponent imple @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); + @Output() customEvent: EventEmitter = new EventEmitter(); public editMode = false; public firstInputValue = ''; @@ -95,11 +100,14 @@ export class DsDynamicLookupComponent extends DsDynamicVocabularyComponent imple protected subs: Subscription[] = []; constructor(protected vocabularyService: VocabularyService, - private cdr: ChangeDetectorRef, + protected cdr: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, + protected formBuilderService: FormBuilderService, + protected modalService: NgbModal, + protected submissionService: SubmissionService, ) { - super(vocabularyService, layoutService, validationService); + super(vocabularyService, layoutService, validationService, formBuilderService, modalService, submissionService); } /** diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index da6de76594c..836b89ebfa4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html @@ -7,60 +7,83 @@
    -
  • {{entry.value}}
  • +
  • + @if (entry.source) { + + } +
    {{entry.value}}
    +
  • @for (item of entry.otherInformation | dsObjNgFor; track item) { -
  • - {{ 'form.other-information.' + item.key | translate }} : {{item.value}} -
  • + @if(!item.key.startsWith('data-')) { +
  • + {{ 'form.other-information.' + item.key | translate }} : {{item.value}} +
  • + } }
    -
  • {{entry.value}}
  • +
  • + @if(entry.source) { + + } +
    {{entry.value}}
    + @if(entry.source) { +
    {{ ('form.entry.source.' + entry.source) | translate}}
    + } +
@if ((isHierarchicalVocabulary() | async) !== true) { -
-
- @if (searching || loadingInitialValue) { - - } - @if (!searching && !loadingInitialValue) { - +
+ +
+
+ @if (searching || loadingInitialValue) { + + } + @if (!searching && !loadingInitialValue) { + + } + @if(otherInfoValues.length > 0 && (model.id === otherInfoKey || otherInfoKey === alternativeNamesKey || 'data-' + model.id === otherInfoKey)) { + + } +
+ + @if (searchFailed) { +
Sorry, suggestions could not be loaded.
}
- - @if (searchFailed) { -
Sorry, suggestions could not be loaded.
- }
} @@ -89,3 +112,18 @@ (keyup)="$event.preventDefault()">
} + +@if (additionalInfoSelectIsOpen) { +
+ {{'form.other-information.selection.' + otherInfoKey | translate}} +
    + @for (info of otherInfoValues; track info) { + +
  • + {{info}} +
  • +
    + } +
+
+} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss index 4f09ab6c1a4..3d73f4549d1 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss @@ -37,3 +37,38 @@ right: 0; transform: translateY(-50%) } + +.source-icon { + height: 20px +} + + +.tree-toggle { + padding: 0.70rem 0.70rem 0 0.70rem ; +} + +.tree-input[readonly]{ + background-color: #fff; + cursor: pointer; +} + +.additional-items-icon { + padding-right: 5rem !important; + cursor: pointer; +} +.additional-info-selection { + z-index: 9999; + width: calc(100% - 10px); + border-radius: 4px; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.175); + .list-item { + cursor: pointer; + } + .list-item:hover { + background-color: var(--bs-dropdown-link-hover-bg); + } +} + +.list-item img { + height: 20px +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index 8a62addfb01..cf6a7154e0a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -20,7 +20,9 @@ import { UntypedFormGroup, } from '@angular/forms'; import { By } from '@angular/platform-browser'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; +import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyOptions } from '@dspace/core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; @@ -28,6 +30,7 @@ import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '@dspace/core/testing/dynamic-form-mock-services'; +import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; import { createTestComponent } from '@dspace/core/testing/utils.test'; import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; @@ -40,22 +43,29 @@ import { DynamicFormsCoreModule, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { getTestScheduler } from 'jasmine-marbles'; import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { SubmissionService } from 'src/app/submission/submission.service'; +import { v4 as uuidv4 } from 'uuid'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive'; import { VocabularyTreeviewComponent } from '../../../../vocabulary-treeview/vocabulary-treeview.component'; +import { FormBuilderService } from '../../../form-builder.service'; import { DsDynamicOneboxComponent } from './dynamic-onebox.component'; import { DynamicOneboxModel } from './dynamic-onebox.model'; + export let ONEBOX_TEST_GROUP; export let ONEBOX_TEST_MODEL_CONFIG; +const validAuthority = uuidv4(); + // Mock class for NgbModalRef export class MockNgbModalRef { componentInstance = { @@ -100,7 +110,7 @@ describe('DsDynamicOneboxComponent test suite', () => { let modalService: any; let html; let modal; - const vocabulary = { + const vocabulary = Object.assign(new Vocabulary(), { id: 'vocabulary', name: 'vocabulary', scrollable: true, @@ -115,9 +125,9 @@ describe('DsDynamicOneboxComponent test suite', () => { url: 'entries', }, }, - }; + }); - const hierarchicalVocabulary = { + const hierarchicalVocabulary = Object.assign(new Vocabulary(), { id: 'hierarchicalVocabulary', name: 'hierarchicalVocabulary', scrollable: true, @@ -132,7 +142,7 @@ describe('DsDynamicOneboxComponent test suite', () => { url: 'entries', }, }, - }; + }); // waitForAsync beforeEach beforeEach(() => { @@ -167,6 +177,10 @@ describe('DsDynamicOneboxComponent test suite', () => { { provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService }, { provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService }, { provide: NgbModal, useValue: modal }, + { provide: FormBuilderService }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + provideMockStore({ initialState: { core: { index: { } } } }), ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); @@ -362,13 +376,13 @@ describe('DsDynamicOneboxComponent test suite', () => { oneboxComponent.group = ONEBOX_TEST_GROUP; oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); const entry = of(Object.assign(new VocabularyEntry(), { - authority: 'test001', + authority: validAuthority, value: 'test001', display: 'test', })); spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); - (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, 'test001'); + (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, validAuthority, 'test001'); oneboxCompFixture.detectChanges(); }); @@ -379,7 +393,7 @@ describe('DsDynamicOneboxComponent test suite', () => { it('should init component properly', fakeAsync(() => { tick(); - expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test001', null, 'test001', 'test')); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, validAuthority, 'test001')); expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByID).toHaveBeenCalled(); })); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 8b3901106e7..c45e0767d35 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -37,11 +37,13 @@ import { import { NgbModal, NgbModalRef, + NgbTooltipModule, NgbTypeahead, NgbTypeaheadModule, NgbTypeaheadSelectItemEvent, } from '@ng-bootstrap/ng-bootstrap'; import { + DynamicFormControlCustomEvent, DynamicFormLayoutService, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; @@ -64,9 +66,12 @@ import { tap, } from 'rxjs/operators'; +import { environment } from '../../../../../../../environments/environment'; +import { SubmissionService } from '../../../../../../submission/submission.service'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive'; import { VocabularyTreeviewModalComponent } from '../../../../vocabulary-treeview-modal/vocabulary-treeview-modal.component'; +import { FormBuilderService } from '../../../form-builder.service'; import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; import { DynamicOneboxModel } from './dynamic-onebox.model'; @@ -82,6 +87,7 @@ import { DynamicOneboxModel } from './dynamic-onebox.model'; AsyncPipe, AuthorityConfidenceStateDirective, FormsModule, + NgbTooltipModule, NgbTypeaheadModule, NgTemplateOutlet, ObjNgFor, @@ -96,6 +102,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); + @Output() customEvent: EventEmitter = new EventEmitter(); @ViewChild('instance') instance: NgbTypeahead; @@ -106,10 +113,14 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false)); click$ = new Subject(); currentValue: any; + previousValue: any; inputValue: any; preloadLevel: number; + additionalInfoSelectIsOpen = false; + alternativeNamesKey = 'alternative-names'; + authorithyIcons = environment.submission.icons.authority.sourceIcons; + - private vocabulary$: Observable; private isHierarchicalVocabulary$: Observable; private subs: Subscription[] = []; @@ -118,8 +129,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple protected layoutService: DynamicFormLayoutService, protected modalService: NgbModal, protected validationService: DynamicFormValidationService, + protected formBuilderService: FormBuilderService, + protected submissionService: SubmissionService, ) { - super(vocabularyService, layoutService, validationService); + super(vocabularyService, layoutService, validationService, formBuilderService, modalService, submissionService); } /** @@ -169,19 +182,18 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple * Initialize the component, setting up the init form value */ ngOnInit() { - if (this.model.value) { - this.setCurrentValue(this.model.value, true); - } - - this.vocabulary$ = this.vocabularyService.findVocabularyById(this.model.vocabularyOptions.name).pipe( - getFirstSucceededRemoteDataPayload(), - distinctUntilChanged(), - ); - + this.initVocabulary(); this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => result.hierarchical), + filter((vocabulary: Vocabulary) => isNotEmpty(vocabulary)), + map((vocabulary: Vocabulary) => vocabulary.hierarchical), ); + this.subs.push(this.isHierarchicalVocabulary$.subscribe((isHierarchical) => { + if (this.model.value) { + this.setCurrentValue(this.model.value, isHierarchical); + } + })); + this.subs.push(this.group.get(this.model.id).valueChanges.pipe( filter((value) => this.currentValue !== value)) .subscribe((value) => { @@ -268,8 +280,23 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple */ onSelectItem(event: NgbTypeaheadSelectItemEvent) { this.inputValue = null; - this.setCurrentValue(event.item); - this.dispatchUpdate(event.item); + const item = event.item; + + if ( hasValue(item.otherInformation)) { + const otherInfoKeys = Object.keys(item.otherInformation).filter((key) => !key.startsWith('data')); + const hasMultipleValues = otherInfoKeys.some(key => hasValue(item.otherInformation[key]) && item.otherInformation[key].includes('|||')); + + if (hasMultipleValues) { + this.setMultipleValuesForOtherInfo(otherInfoKeys, item); + } else { + this.resetMultipleValuesForOtherInfo(); + } + } else { + this.resetMultipleValuesForOtherInfo(); + } + + this.setCurrentValue(item); + this.dispatchUpdate(item); } /** @@ -293,6 +320,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple modalRef.result.then((result: VocabularyEntryDetail) => { if (result) { this.currentValue = result; + this.previousValue = result; this.dispatchUpdate(result); } }, () => { @@ -323,19 +351,156 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple .subscribe((formValue: FormFieldMetadataValueObject) => { this.changeLoadingInitialValueStatus(false); this.currentValue = formValue; + this.previousValue = formValue; this.cdr.detectChanges(); }); } else { if (isEmpty(value)) { result = ''; } else { - result = value.value; + result = value; } + this.currentValue = null; + this.cdr.detectChanges(); this.currentValue = result; + this.previousValue = result; this.cdr.detectChanges(); } + if (hasValue(this.currentValue?.otherInformation)) { + const infoKeys = Object.keys(this.currentValue.otherInformation); + this.setMultipleValuesForOtherInfo(infoKeys, this.currentValue); + } + } + + /** + * Get the other information value removing the authority section (after the last ::) + * @param itemValue the initial item value + * @param itemKey + */ + getOtherInfoValue(itemValue: string, itemKey: string): string { + if (!itemValue || !itemValue.includes('::')) { + return itemValue; + } + + if (itemValue.includes('|||')) { + let result = ''; + const values = itemValue.split('|||').map(item => item.substring(0, item.lastIndexOf('::'))); + const lastIndex = values.length - 1; + values.forEach((value, i) => result += i === lastIndex ? value : value + ' · '); + return result; + } + + return itemValue.substring(0, itemValue.lastIndexOf('::')); + } + + toggleOtherInfoSelection() { + this.additionalInfoSelectIsOpen = !this.additionalInfoSelectIsOpen; + } + + selectAlternativeInfo(info: string) { + this.searching = true; + + if (this.otherInfoKey !== this.alternativeNamesKey) { + this.otherInfoValue = info; + } else { + this.otherName = info; + } + + const temp = this.createVocabularyObject(info, info, this.currentValue.otherInformation); + this.currentValue = null; + this.currentValue = temp; + + const unformattedOtherInfoValue = this.otherInfoValuesUnformatted.find((unformattedItem) => { + return unformattedItem.startsWith(info); + }); + + if (hasValue(unformattedOtherInfoValue)) { + const lastIndexOfSeparator = unformattedOtherInfoValue.lastIndexOf('::'); + if (lastIndexOfSeparator !== -1) { + this.currentValue.authority = unformattedOtherInfoValue.substring(lastIndexOfSeparator + 2); + } else { + this.currentValue.authority = undefined; + } + } + + const event = { + item: this.currentValue, + } as any; + + this.onSelectItem(event); + this.searching = false; + this.toggleOtherInfoSelection(); + } + + + setMultipleValuesForOtherInfo(keys: string[], item: any) { + const hasAlternativeNames = keys.includes(this.alternativeNamesKey); + + this.otherInfoKey = hasAlternativeNames ? this.alternativeNamesKey : keys.find(key => hasValue(item.otherInformation[key]) && item.otherInformation[key].includes('|||')); + this.otherInfoValuesUnformatted = item.otherInformation[this.otherInfoKey] ? item.otherInformation[this.otherInfoKey].split('|||') : []; + + this.otherInfoValues = this.otherInfoValuesUnformatted.map(unformattedItem => { + let lastIndexOfSeparator = unformattedItem.lastIndexOf('::'); + if (lastIndexOfSeparator === -1) { + lastIndexOfSeparator = undefined; + } + return unformattedItem.substring(0, lastIndexOfSeparator); + }); + + if (hasAlternativeNames) { + this.otherName = hasValue(this.otherName) ? this.otherName : this.otherInfoValues[0]; + } + + if (keys.length > 1) { + this.otherInfoValue = hasValue(this.otherInfoValue) ? this.otherInfoValue : this.otherInfoValues[0]; + } + } + + resetMultipleValuesForOtherInfo() { + this.otherInfoKey = undefined; + this.otherInfoValuesUnformatted = []; + this.otherInfoValues = []; + this.otherInfoValue = undefined; + this.otherName = undefined; + } + + createVocabularyObject(display, value, otherInformation) { + return Object.assign(new VocabularyEntry(), this.model.value, { + display: display, + value: value, + otherInformation: otherInformation, + type: 'vocabularyEntry', + }); + } + + + /** + * Hide image on error + * @param image + */ + handleImgError(image: HTMLElement): void { + image.style.display = 'none'; + } + + /** + * Get configured icon for each authority source + * @param source + */ + getAuthoritySourceIcon(source: string, image: HTMLElement): string { + if (hasValue(this.authorithyIcons)) { + const iconPath = this.authorithyIcons.find(icon => icon.source === source)?.path; + + if (!hasValue(iconPath)) { + this.handleImgError(image); + } + + return iconPath; + } else { + this.handleImgError(image); + } + return ''; } ngOnDestroy(): void { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts index bce1dc5f19d..5213f64d813 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts @@ -14,12 +14,14 @@ export const DYNAMIC_FORM_CONTROL_TYPE_ONEBOX = 'ONEBOX'; export interface DsDynamicOneboxModelConfig extends DsDynamicInputModelConfig { minChars?: number; value?: any; + submissionScope?: string; } export class DynamicOneboxModel extends DsDynamicInputModel { @serializable() minChars: number; @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_ONEBOX; + @serializable() submissionScope: string; constructor(config: DsDynamicOneboxModelConfig, layout?: DynamicFormControlLayout) { @@ -27,6 +29,7 @@ export class DynamicOneboxModel extends DsDynamicInputModel { this.autoComplete = AUTOCOMPLETE_OFF; this.minChars = config.minChars || 3; + this.submissionScope = config.submissionScope; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts index a61c27f5405..43ac5796041 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts @@ -1,6 +1,9 @@ import { FormRowModel } from '@dspace/core/config/models/config-submission-form.model'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from '@dspace/core/shared/form/ds-dynamic-form-constants'; +import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; +import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model'; import { + hasValue, isEmpty, isNull, } from '@dspace/shared/utils/empty.util'; @@ -60,8 +63,8 @@ export class DynamicRelationGroupModel extends DsDynamicInputModel { return (value.length === 1 && isNull(value[0][this.mandatoryField])); } - getGroupValue(): any[] { - if (isEmpty(this.value)) { + getGroupValue(value?: any): any[] { + if (isEmpty(this.value) && isEmpty(value)) { // If items is empty, last element has been removed // so emit an empty value that allows to dispatch // a remove JSON PATCH operation @@ -72,6 +75,17 @@ export class DynamicRelationGroupModel extends DsDynamicInputModel { emptyItem[field] = null; }); return [emptyItem]; + } else if ((this.value instanceof VocabularyEntry || this.value instanceof FormFieldMetadataValueObject) || + (hasValue(value) && (value instanceof VocabularyEntry || value instanceof FormFieldMetadataValueObject))) { + + const emptyItem = {}; + emptyItem[this.mandatoryField] = hasValue(value) && (value instanceof VocabularyEntry || value instanceof FormFieldMetadataValueObject) ? value : this.value; + this.relationFields + .forEach((field) => { + emptyItem[field] = hasValue((this.value as any).otherInformation) ? (this.value as any).otherInformation[field] : null; + }); + + return [emptyItem]; } return this.value as any[]; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index e7c3e7ed835..c5796307369 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -28,20 +28,27 @@ import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '@dspace/core/testing/dynamic-form-mock-services'; +import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; import { createTestComponent, hasClass, } from '@dspace/core/testing/utils.test'; import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { + NgbModal, + NgbModule, +} from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { SubmissionService } from 'src/app/submission/submission.service'; +import { FormBuilderService } from '../../../form-builder.service'; import { DsDynamicScrollableDropdownComponent } from './dynamic-scrollable-dropdown.component'; import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model'; @@ -102,7 +109,11 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService }, { provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService }, + { provide: FormBuilderService }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + NgbModal, + provideMockStore(), ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts index 11894f8420c..45dcec3990e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts @@ -35,8 +35,10 @@ import { import { NgbDropdown, NgbDropdownModule, + NgbModal, } from '@ng-bootstrap/ng-bootstrap'; import { + DynamicFormControlCustomEvent, DynamicFormLayoutService, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; @@ -53,11 +55,12 @@ import { take, tap, } from 'rxjs/operators'; +import { FormBuilderService } from 'src/app/shared/form/builder/form-builder.service'; +import { SubmissionService } from 'src/app/submission/submission.service'; import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model'; - /** * Component representing a dropdown input field */ @@ -82,6 +85,7 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); + @Output() customEvent: EventEmitter = new EventEmitter(); public currentValue: Observable; public loading = false; @@ -111,10 +115,13 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom protected cdr: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, + protected formBuilderService: FormBuilderService, + protected modalService: NgbModal, + protected submissionService: SubmissionService, protected parentInjector: Injector, @Inject(APP_DATA_SERVICES_MAP) private dataServiceMap: LazyDataServicesMap, ) { - super(vocabularyService, layoutService, validationService); + super(vocabularyService, layoutService, validationService, formBuilderService, modalService, submissionService); } /** diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index 0df3ff9796e..6372a04092a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -18,6 +18,7 @@ import { UntypedFormControl, UntypedFormGroup, } from '@angular/forms'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyOptions } from '@dspace/core/submission/vocabularies/models/vocabulary-options.model'; @@ -26,6 +27,7 @@ import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '@dspace/core/testing/dynamic-form-mock-services'; +import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; import { createTestComponent } from '@dspace/core/testing/utils.test'; import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub'; import { @@ -37,10 +39,13 @@ import { DynamicFormsCoreModule, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; +import { SubmissionService } from 'src/app/submission/submission.service'; import { Chips } from '../../../../chips/models/chips.model'; +import { FormBuilderService } from '../../../form-builder.service'; import { DsDynamicTagComponent } from './dynamic-tag.component'; import { DynamicTagModel } from './dynamic-tag.model'; @@ -112,6 +117,10 @@ describe('DsDynamicTagComponent test suite', () => { { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService }, { provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService }, + { provide: FormBuilderService }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + provideMockStore(), + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }); @@ -232,9 +241,9 @@ describe('DsDynamicTagComponent test suite', () => { tagComp.group = TAG_TEST_GROUP; tagComp.model = new DynamicTagModel(TAG_TEST_MODEL_CONFIG); modelValue = [ - new FormFieldMetadataValueObject('a', null, 'test001'), - new FormFieldMetadataValueObject('b', null, 'test002'), - new FormFieldMetadataValueObject('c', null, 'test003'), + new FormFieldMetadataValueObject('a', null, null, 'test001'), + new FormFieldMetadataValueObject('b', null, null, 'test002'), + new FormFieldMetadataValueObject('c', null, null, 'test003'), ]; tagComp.model.value = modelValue; tagFixture.detectChanges(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index 9270ca61ed9..2f053cfc80c 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -25,11 +25,13 @@ import { isNotEmpty, } from '@dspace/shared/utils/empty.util'; import { + NgbModal, NgbTypeahead, NgbTypeaheadModule, NgbTypeaheadSelectItemEvent, } from '@ng-bootstrap/ng-bootstrap'; import { + DynamicFormControlCustomEvent, DynamicFormLayoutService, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; @@ -49,8 +51,10 @@ import { } from 'rxjs/operators'; import { environment } from '../../../../../../../environments/environment'; +import { SubmissionService } from '../../../../../../submission/submission.service'; import { ChipsComponent } from '../../../../chips/chips.component'; import { Chips } from '../../../../chips/models/chips.model'; +import { FormBuilderService } from '../../../form-builder.service'; import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; import { DynamicTagModel } from './dynamic-tag.model'; @@ -76,6 +80,7 @@ export class DsDynamicTagComponent extends DsDynamicVocabularyComponent implemen @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); + @Output() customEvent: EventEmitter = new EventEmitter(); @ViewChild('instance') instance: NgbTypeahead; @@ -92,8 +97,11 @@ export class DsDynamicTagComponent extends DsDynamicVocabularyComponent implemen private cdr: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, + protected formBuilderService: FormBuilderService, + protected modalService: NgbModal, + protected submissionService: SubmissionService, ) { - super(vocabularyService, layoutService, validationService); + super(vocabularyService, layoutService, validationService, formBuilderService, modalService, submissionService); } /** diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 7645e329c5f..ce1b209d335 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -102,6 +102,8 @@ describe('FormBuilderService test suite', () => { const vocabularyOptions: VocabularyOptions = { name: 'type_programme', + metadata: null, + scope: null, closed: false, }; diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index b7829896cfa..d3c4cee6563 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -4,6 +4,7 @@ import { } from '@angular/core'; import { AbstractControl, + FormArray, UntypedFormControl, UntypedFormGroup, } from '@angular/forms'; @@ -54,7 +55,10 @@ import { import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; -import { DynamicRelationGroupModel } from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { + DynamicRelationGroupModel, + DynamicRelationGroupModelConfig, +} from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; import { RowParser } from './parsers/row-parser'; @@ -410,6 +414,15 @@ export class FormBuilderService extends DynamicFormService { return (tempModel.id !== tempModel.name) ? tempModel.name : tempModel.id; } + /** + * Add new form model to formModels map + * @param id id of model + * @param model model + */ + addFormModel(id: string, model: DynamicFormControlModel[]): void { + this.formModels.set(id, model); + } + /** * If present, remove form model from formModels map * @param id id of model @@ -439,6 +452,64 @@ export class FormBuilderService extends DynamicFormService { } } + /** + * This method searches a field in all forms instantiated + * by form.component and, if found, it updates its value + * + * @param fieldId id of field to update + * @param value new value to set + * @return the model updated if found + */ + updateModelValue(fieldId: string, value: FormFieldMetadataValueObject): DynamicFormControlModel { + let returnModel = null; + [...this.formModels.keys()].forEach((formId) => { + const models = this.formModels.get(formId); + let fieldModel: any = this.findById(fieldId, models); + if (hasValue(fieldModel) && !fieldModel.hidden) { + const isIterable = (typeof value[Symbol.iterator] === 'function'); + if (isNotEmpty(value)) { + if (fieldModel.repeatable && isNotEmpty(fieldModel.value) && !(!isIterable && fieldModel instanceof DynamicRelationGroupModel)) { + // if model is repeatable and has already a value add a new field instead of replacing it + const formGroup = this.formGroups.get(formId); + const arrayContext = fieldModel.parent?.context; + if (isNotEmpty(formGroup) && isNotEmpty(arrayContext)) { + const formArrayControl = this.getFormControlByModel(formGroup, arrayContext) as FormArray; + const index = arrayContext?.groups?.length; + this.insertFormArrayGroup(index, formArrayControl, arrayContext); + const newAddedModel: any = this.findById(fieldId, models, index); + this.detectChanges(); + newAddedModel.value = value; + returnModel = newAddedModel; + } + } else { + + if ((!isIterable && fieldModel instanceof DynamicRelationGroupModel) && isEmpty(fieldModel.value)) { + const config: DynamicRelationGroupModelConfig = { + submissionId: fieldModel.submissionId, + formConfiguration: fieldModel.formConfiguration, + mandatoryField: fieldModel.mandatoryField, + relationFields: fieldModel.relationFields, + scopeUUID: fieldModel.scopeUUID, + submissionScope: fieldModel.submissionScope, + repeatable: fieldModel.repeatable, + metadataFields: fieldModel.metadataFields, + hasSelectableMetadata: fieldModel.hasSelectableMetadata, + id: fieldModel.id, + value: fieldModel.getGroupValue(value), + }; + fieldModel = new DynamicRelationGroupModel(config); + } else { + fieldModel.value = value; + } + returnModel = fieldModel; + + } + } + } + }); + return returnModel; + } + /** * Calculate the metadata list related to the event. * @param event diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts index 815fefe2afd..2ecd35a1843 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -35,7 +35,7 @@ export class DropdownFieldParser extends FieldParser { let layout: DynamicFormControlLayout; if (isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) { - this.setVocabularyOptions(dropdownModelConfig); + this.setVocabularyOptions(dropdownModelConfig, this.parserOptions.collectionUUID); if (isNotEmpty(fieldValue)) { dropdownModelConfig.value = fieldValue; } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index be849852e50..187cd6072ca 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -139,10 +139,12 @@ export abstract class FieldParser { } } - public setVocabularyOptions(controlModel) { + public setVocabularyOptions(controlModel, scope) { if (isNotEmpty(this.configData.selectableMetadata) && isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) { controlModel.vocabularyOptions = new VocabularyOptions( this.configData.selectableMetadata[0].controlledVocabulary, + this.configData.selectableMetadata[0].metadata, + scope, this.configData.selectableMetadata[0].closed, ); } diff --git a/src/app/shared/form/builder/parsers/list-field-parser.ts b/src/app/shared/form/builder/parsers/list-field-parser.ts index 4094f86b164..461ad4b9c3f 100644 --- a/src/app/shared/form/builder/parsers/list-field-parser.ts +++ b/src/app/shared/form/builder/parsers/list-field-parser.ts @@ -25,7 +25,7 @@ export class ListFieldParser extends FieldParser { } }); } - this.setVocabularyOptions(listModelConfig); + this.setVocabularyOptions(listModelConfig, this.parserOptions.collectionUUID); } let listModel; diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.ts index a4c92df0eeb..6e2a48d5333 100644 --- a/src/app/shared/form/builder/parsers/lookup-field-parser.ts +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.ts @@ -12,7 +12,7 @@ export class LookupFieldParser extends FieldParser { if (this.configData.selectableMetadata[0].controlledVocabulary) { const lookupModelConfig: DynamicLookupModelConfig = this.initModel(null, label); - this.setVocabularyOptions(lookupModelConfig); + this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID); this.setValues(lookupModelConfig, fieldValue, true); diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts index 72249ea3550..53bac998c01 100644 --- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts @@ -12,7 +12,7 @@ export class LookupNameFieldParser extends FieldParser { if (this.configData.selectableMetadata[0].controlledVocabulary) { const lookupModelConfig: DynamicLookupNameModelConfig = this.initModel(null, label); - this.setVocabularyOptions(lookupModelConfig); + this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID); this.setValues(lookupModelConfig, fieldValue, true); diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts index 16e0b30b44a..7cf25e99c79 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts @@ -78,14 +78,14 @@ export class OneboxFieldParser extends FieldParser { } selectModelConfig.disabled = inputModelConfig.readOnly; inputSelectGroup.readOnly = selectModelConfig.disabled && inputModelConfig.readOnly; - + inputSelectGroup.language = inputModelConfig.language; inputSelectGroup.group.push(new DynamicSelectModel(selectModelConfig, clsSelect)); inputSelectGroup.group.push(new DsDynamicInputModel(inputModelConfig, clsInput)); return new DynamicQualdropModel(inputSelectGroup, clsGroup); } else if (this.configData.selectableMetadata[0].controlledVocabulary) { const oneboxModelConfig: DsDynamicOneboxModelConfig = this.initModel(null, label); - this.setVocabularyOptions(oneboxModelConfig); + this.setVocabularyOptions(oneboxModelConfig, this.parserOptions.collectionUUID); this.setValues(oneboxModelConfig, fieldValue, true); return new DynamicOneboxModel(oneboxModelConfig); diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.ts b/src/app/shared/form/builder/parsers/tag-field-parser.ts index d782af1c143..7cead0de898 100644 --- a/src/app/shared/form/builder/parsers/tag-field-parser.ts +++ b/src/app/shared/form/builder/parsers/tag-field-parser.ts @@ -12,7 +12,7 @@ export class TagFieldParser extends FieldParser { const tagModelConfig: DynamicTagModelConfig = this.initModel(null, label); if (this.configData.selectableMetadata[0].controlledVocabulary && this.configData.selectableMetadata[0].controlledVocabulary.length > 0) { - this.setVocabularyOptions(tagModelConfig); + this.setVocabularyOptions(tagModelConfig, this.parserOptions.collectionUUID); } this.setValues(tagModelConfig, fieldValue, null, true); diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 0ec572a6f98..557b7b5a98e 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -10,6 +10,7 @@ import { } from '@angular/core'; import { AbstractControl, + FormControl, ReactiveFormsModule, UntypedFormArray, UntypedFormControl, @@ -305,7 +306,17 @@ export class FormComponent implements OnDestroy, OnInit { } onCustomEvent(event: any) { - this.customEvent.emit(event); + if (event?.type === 'authorityEnrichment') { + event.$event.updatedModels.forEach((model) => { + const control: FormControl = this.formBuilderService.getFormControlByModel(this.formGroup, model) as FormControl; + if (control) { + const changeEvent = this.formBuilderService.createDynamicFormControlEvent(control, control.parent as UntypedFormGroup, model, 'change'); + this.onChange(changeEvent); + } + }); + } else { + this.customEvent.emit(event); + } } onFocus(event: DynamicFormControlEvent): void { diff --git a/src/app/shared/form/testing/form-builder-service.mock.ts b/src/app/shared/form/testing/form-builder-service.mock.ts index 9ef34227d21..087bd7a5199 100644 --- a/src/app/shared/form/testing/form-builder-service.mock.ts +++ b/src/app/shared/form/testing/form-builder-service.mock.ts @@ -45,5 +45,9 @@ export function getMockFormBuilderService(): FormBuilderService { ], }, ), + removeFormModel: {}, + addFormModel: {}, + updateValue: {}, + addFormGroups: {}, }); } diff --git a/src/app/shared/form/testing/form-models.mock.ts b/src/app/shared/form/testing/form-models.mock.ts index 087cff1a55f..36eca95325e 100644 --- a/src/app/shared/form/testing/form-models.mock.ts +++ b/src/app/shared/form/testing/form-models.mock.ts @@ -154,7 +154,7 @@ const relationGroupConfig = { export const MockRelationModel: DynamicRelationGroupModel = new DynamicRelationGroupModel(relationGroupConfig); export const inputWithLanguageAndAuthorityConfig = { - vocabularyOptions: new VocabularyOptions('testAuthority', false), + vocabularyOptions: new VocabularyOptions('testAuthority', null, null, false), languageCodes: [ { display: 'English', @@ -209,7 +209,7 @@ export const inputWithLanguageConfig = { export const mockInputWithLanguageModel = new DsDynamicInputModel(inputWithLanguageConfig); export const inputWithLanguageAndAuthorityArrayConfig = { - vocabularyOptions: new VocabularyOptions('testAuthority', false), + vocabularyOptions: new VocabularyOptions('testAuthority', null, null, false), languageCodes: [ { display: 'English', diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts index 6d3eaba24b1..d43ff3e744f 100644 --- a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts +++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts @@ -14,7 +14,7 @@ describe('VocabularyTreeviewModalComponent', () => { let fixture: ComponentFixture; const modalStub = jasmine.createSpyObj('modalStub', ['close']); - const vocabularyOptions = new VocabularyOptions('vocabularyTest', false); + const vocabularyOptions = new VocabularyOptions('vocabularyTest', null, null, false); beforeEach(async () => { await TestBed.configureTestingModule({ diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts index d9b45aea026..08f407363df 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts @@ -45,7 +45,7 @@ describe('VocabularyTreeviewComponent test suite', () => { const emptyNodeMap = new Map(); const storedNodeMap = new Map().set('test', new TreeviewFlatNode(item2)); const nodeMap = new Map().set('test', new TreeviewFlatNode(item)); - const vocabularyOptions = new VocabularyOptions('vocabularyTest', false); + const vocabularyOptions = new VocabularyOptions('vocabularyTest', null, null, false); const modalStub = jasmine.createSpyObj('modalStub', ['close']); const vocabularyTreeviewServiceStub = jasmine.createSpyObj('VocabularyTreeviewService', { initialize: jasmine.createSpy('initialize'), @@ -292,7 +292,7 @@ describe('VocabularyTreeviewComponent test suite', () => { }) class TestComponent { - vocabularyOptions: VocabularyOptions = new VocabularyOptions('vocabularyTest', false); + vocabularyOptions: VocabularyOptions = new VocabularyOptions('vocabularyTest', null, null, false); preloadLevel = 2; } diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts index f88cff2e75a..24060eb8746 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -177,7 +177,7 @@ describe('VocabularyTreeviewService test suite', () => { searchNodeMap = new Map([ [item.id, searchItemNode], ]); - vocabularyOptions = new VocabularyOptions('vocabularyTest', false); + vocabularyOptions = new VocabularyOptions('vocabularyTest', null, null,false); } beforeEach(waitForAsync(() => { diff --git a/src/app/shared/image.utils.ts b/src/app/shared/image.utils.ts new file mode 100644 index 00000000000..a6070364220 --- /dev/null +++ b/src/app/shared/image.utils.ts @@ -0,0 +1,34 @@ +import { + Observable, + of, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +export const getDefaultImageUrlByEntityType = (entityType: string): Observable => { + const fallbackImage = 'assets/images/file-placeholder.svg'; + + if (!entityType) { + return of(fallbackImage); + } + + const defaultImage = `assets/images/${entityType.toLowerCase()}-placeholder.svg`; + return checkImageExists(defaultImage).pipe(map((exists) => exists ? defaultImage : fallbackImage)); +}; + +const checkImageExists = (url: string): Observable => { + return new Observable((observer) => { + const img = new Image(); + + img.onload = () => { + observer.next(true); + observer.complete(); + }; + + img.onerror = () => { + observer.next(false); + observer.complete(); + }; + + img.src = url; + }); +}; diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.html b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.html new file mode 100644 index 00000000000..7a19f47d347 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.html @@ -0,0 +1,21 @@ +
+ @if (isLoading()) { +
+
+
+ +
+
+
+ } + + @if (src() !== null) { + + } + @if (src() === null && isLoading() === false) { +
+ +
+ } +
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.scss b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.scss new file mode 100644 index 00000000000..598ca7ceed4 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.scss @@ -0,0 +1,10 @@ +:host{ + img { + height: 80px; + width: 80px; + min-width: 80px; + border: 1px solid #ccc; + border-radius: 50%; + object-fit: cover; + } +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.spec.ts new file mode 100644 index 00000000000..472283ceedd --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.spec.ts @@ -0,0 +1,82 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { FileService } from '@dspace/core/shared/file.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { ThemedLoadingComponent } from '../../loading/themed-loading.component'; +import { MetadataLinkViewAvatarPopoverComponent } from './metadata-link-view-avatar-popover.component'; + +describe('MetadataLinkViewAvatarPopoverComponent', () => { + let component: MetadataLinkViewAvatarPopoverComponent; + let fixture: ComponentFixture; + let authService; + let authorizationService; + let fileService; + + beforeEach(waitForAsync(() => { + authService = jasmine.createSpyObj('AuthService', { + isAuthenticated: of(true), + }); + authorizationService = jasmine.createSpyObj('AuthorizationService', { + isAuthorized: of(true), + }); + fileService = jasmine.createSpyObj('FileService', { + retrieveFileDownloadLink: null, + }); + + TestBed.configureTestingModule({ + imports: [ + MetadataLinkViewAvatarPopoverComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: FileService, useValue: fileService }, + ], + }) + .overrideComponent(MetadataLinkViewAvatarPopoverComponent, { remove: { imports: [ThemedLoadingComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataLinkViewAvatarPopoverComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set fallback image if no entity type', (done) => { + component.ngOnInit(); + component.placeholderImageUrl$.subscribe((url) => { + expect(url).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); + + it('should set correct placeholder image based on entity type if image exists', (done) => { + component.entityType = 'OrgUnit'; + component.ngOnInit(); + component.placeholderImageUrl$.subscribe((url) => { + expect(url).toBe('assets/images/orgunit-placeholder.svg'); + done(); + }); + }); + + it('should set correct fallback image if image does not exists', (done) => { + component.entityType = 'missingEntityType'; + component.ngOnInit(); + component.placeholderImageUrl$.subscribe((url) => { + expect(url).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); +}); diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.ts b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.ts new file mode 100644 index 00000000000..42fb08d9216 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.ts @@ -0,0 +1,46 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { ThumbnailComponent } from 'src/app/thumbnail/thumbnail.component'; + +import { getDefaultImageUrlByEntityType } from '../../image.utils'; +import { ThemedLoadingComponent } from '../../loading/themed-loading.component'; +import { SafeUrlPipe } from '../../utils/safe-url-pipe'; + +@Component({ + selector: 'ds-metadata-link-view-avatar-popover', + templateUrl: './metadata-link-view-avatar-popover.component.html', + styleUrls: ['./metadata-link-view-avatar-popover.component.scss'], + imports: [ + AsyncPipe, + NgClass, + SafeUrlPipe, + ThemedLoadingComponent, + TranslateModule, + ], +}) +export class MetadataLinkViewAvatarPopoverComponent extends ThumbnailComponent implements OnInit { + + + /** + * Placeholder image url that changes based on entity type + */ + placeholderImageUrl$: Observable; + + /** + * The entity type of the item which the avatar belong + */ + @Input() entityType: string; + + ngOnInit() { + this.placeholderImageUrl$ = getDefaultImageUrlByEntityType(this.entityType); + } +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.html b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.html new file mode 100644 index 00000000000..c62715fba13 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.html @@ -0,0 +1,23 @@ +@let hasBadge = hasOrcidBadge(); +@let orcidUrl = orcidUrl$ | async; + +@if (hasBadge || orcidUrl) { + + @if (orcidUrl) { + + {{ metadataValue }} + + } @else { + {{ metadataValue }} + } + + @if (hasBadge) { + orcid-logo + } + +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.scss b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.scss new file mode 100644 index 00000000000..b92a52cd35d --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.scss @@ -0,0 +1,4 @@ +.orcid-icon { + height: 1.2rem; + padding-left: 0.3rem; +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.spec.ts new file mode 100644 index 00000000000..cb89a89f67f --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.spec.ts @@ -0,0 +1,74 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.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 { Item } from 'src/app/core/shared/item.model'; +import { MetadataValue } from 'src/app/core/shared/metadata.models'; + +import { MetadataLinkViewOrcidComponent } from './metadata-link-view-orcid.component'; + +describe('MetadataLinkViewOrcidComponent', () => { + let component: MetadataLinkViewOrcidComponent; + let fixture: ComponentFixture; + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['https://sandbox.orcid.org'] }), + }); + + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': '0000-0001-8918-3592', + 'language': 'en_US', + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'person.identifier.orcid': [metadataValue], + 'dspace.orcid.authenticated': [ + { + language: null, + value: 'authenticated', + }, + ], + }, + uuid: 'test-item-uuid', + }, + ); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, MetadataLinkViewOrcidComponent], + providers: [ + { provide: ConfigurationDataService, useValue: configurationDataService }, + ], + }) + .compileComponents(); + + fixture = TestBed.createComponent(MetadataLinkViewOrcidComponent); + component = fixture.componentInstance; + component.itemValue = testItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.ts b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.ts new file mode 100644 index 00000000000..cc0deba1ce4 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.ts @@ -0,0 +1,64 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + map, + Observable, +} from 'rxjs'; + +@Component({ + selector: 'ds-metadata-link-view-orcid', + templateUrl: './metadata-link-view-orcid.component.html', + styleUrls: ['./metadata-link-view-orcid.component.scss'], + imports: [ + AsyncPipe, + NgbTooltipModule, + TranslateModule, + ], +}) +export class MetadataLinkViewOrcidComponent implements OnInit { + /** + * Item value to display the metadata for + */ + @Input() itemValue: Item; + + metadataValue: string; + + orcidUrl$: Observable; + + constructor(protected configurationService: ConfigurationDataService) {} + + ngOnInit(): void { + this.orcidUrl$ = this.configurationService + .findByPropertyName('orcid.domain-url') + .pipe( + getFirstCompletedRemoteData(), + map((propertyPayload: RemoteData) => + propertyPayload.hasSucceeded ? + (propertyPayload.payload?.values?.length > 0 ? propertyPayload.payload.values[0] : null) + : null, + ), + ); + this.metadataValue = this.itemValue.firstMetadataValue( + 'person.identifier.orcid', + ); + } + + public hasOrcid(): boolean { + return this.itemValue.hasMetadata('person.identifier.orcid'); + } + + public hasOrcidBadge(): boolean { + return this.itemValue.hasMetadata('dspace.orcid.authenticated'); + } +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.html b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.html new file mode 100644 index 00000000000..fa18562e38d --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.html @@ -0,0 +1,77 @@ +
+ +
+ @if (item.thumbnail | async) { + + } +
{{title}}
+
+ + @for (metadata of entityMetdataFields; track metadata) { + @if (item.hasMetadata(metadata)) { +
+
+ {{ "metadata-link-view.popover.label." + (isOtherEntityType ? "other" : item.entityType) + "." + metadata | translate }} +
+
+ @if (longTextMetadataList.includes(metadata)) { + + {{ item.firstMetadataValue(metadata) }} + + } + @if (isLink(item.firstMetadataValue(metadata)) && !getSourceSubTypeIdentifier(metadata)) { + + {{ item.firstMetadataValue(metadata) }} + + } + @if (getSourceSubTypeIdentifier(metadata)) { +
+ + @if (isLink(rorValue)) { + + {{ item.firstMetadataValue(metadata) }} + + } + @if (!isLink(rorValue)) { + + {{ item.firstMetadataValue(metadata) }} + + } + + source-logo +
+ } + @if (!isLink(item.firstMetadataValue(metadata)) && !longTextMetadataList.includes(metadata)) { +
+ @if (metadata === 'person.identifier.orcid') { + + } @else { + {{ item.firstMetadataValue(metadata) }} + } +
+ } +
+
+ } + } + + +
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.scss b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.scss new file mode 100644 index 00000000000..47ea2c353d2 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.scss @@ -0,0 +1,6 @@ +.source-icon { + height: var(--ds-identifier-subtype-icon-height); + min-height: 16px; + width: auto; + padding-left: 0.3rem; +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.spec.ts new file mode 100644 index 00000000000..6fce6f93233 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.spec.ts @@ -0,0 +1,178 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; +import { Item } from 'src/app/core/shared/item.model'; +import { MetadataValueFilter } from 'src/app/core/shared/metadata.models'; +import { environment } from 'src/environments/environment.test'; + +import { MetadataLinkViewAvatarPopoverComponent } from '../metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component'; +import { MetadataLinkViewOrcidComponent } from '../metadata-link-view-orcid/metadata-link-view-orcid.component'; +import { MetadataLinkViewPopoverComponent } from './metadata-link-view-popover.component'; + +describe('MetadataLinkViewPopoverComponent', () => { + let component: MetadataLinkViewPopoverComponent; + let fixture: ComponentFixture; + + + const itemMock = Object.assign(new Item(), { + uuid: '1234-1234-1234-1234', + entityType: 'Publication', + + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { + return itemMock.metadata[keyOrKeys as string][0].value; + }, + + metadata: { + 'dc.title': [ + { + value: 'file name', + language: null, + }, + ], + 'dc.identifier.uri': [ + { + value: 'http://example.com', + language: null, + }, + ], + 'dc.description.abstract': [ + { + value: 'Long text description', + language: null, + }, + ], + 'organization.identifier.ror': [ + { + value: 'https://ror.org/1234', + language: null, + }, + ], + 'person.identifier.orcid': [ + { + value: 'https://orcid.org/0000-0000-0000-0000', + language: null, + }, + ], + 'dspace.entity.type': [ + { + value: 'Person', + language: null, + }, + ], + }, + thumbnail: createSuccessfulRemoteDataObject$(new Bitstream()), + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), MetadataLinkViewPopoverComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: ActivatedRoute, useValue: { snapshot: { data: { dso: itemMock } } } }, + ], + }) + .overrideComponent(MetadataLinkViewPopoverComponent, { remove: { imports: [MetadataLinkViewOrcidComponent, MetadataLinkViewAvatarPopoverComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataLinkViewPopoverComponent); + component = fixture.componentInstance; + component.item = itemMock; + itemMock.firstMetadataValue = jasmine.createSpy() + .withArgs('dspace.entity.type').and.returnValue('Person') + .withArgs('dc.title').and.returnValue('Test Title') + .withArgs('dc.identifier.uri').and.returnValue('http://example.com') + .withArgs('dc.description.abstract').and.returnValue('Long text description') + .withArgs('organization.identifier.ror').and.returnValue('https://ror.org/1234') + .withArgs('person.identifier.orcid').and.returnValue('https://orcid.org/0000-0000-0000-0000') + .withArgs('dc.nonexistent').and.returnValue(null); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the item title', () => { + const titleElement = fixture.debugElement.query(By.css('.font-weight-bold.h4')); + expect(titleElement.nativeElement.textContent).toContain('Test Title'); + }); + + it('should display a link for each metadata field that is a valid link', () => { + component.entityMetdataFields = ['dc.identifier.uri']; + fixture.detectChanges(); + const linkElement = fixture.debugElement.query(By.css('a[href="http://example.com"]')); + expect(linkElement).toBeTruthy(); + }); + + it('should retrieve the identifier subtype configuration based on the given metadata value', () => { + const metadataValue = 'organization.identifier.ror'; + const expectedSubtypeConfig = environment.identifierSubtypes.find((config) => config.name === 'ror'); + expect(component.getSourceSubTypeIdentifier(metadataValue)).toEqual(expectedSubtypeConfig); + }); + + + it('should check if a given metadata value is a valid link', () => { + const validLink = 'http://example.com'; + const invalidLink = 'not a link'; + expect(component.isLink(validLink)).toBeTrue(); + expect(component.isLink(invalidLink)).toBeFalse(); + }); + + it('should display the "more info" link with the correct router link', () => { + spyOn(component, 'getItemPageRoute').and.returnValue('/item/' + itemMock.uuid); + fixture.detectChanges(); + const moreInfoLinkElement = fixture.debugElement.query(By.css('a[data-test="more-info-link"]')); + expect(moreInfoLinkElement.nativeElement.href).toContain('/item/' + itemMock.uuid); + }); + + it('should display the avatar popover when item has a thumbnail', () => { + const avatarPopoverElement = fixture.debugElement.query(By.css('ds-metadata-link-view-avatar-popover')); + expect(avatarPopoverElement).toBeTruthy(); + }); + + describe('getTitleFromMetadataList', () => { + + it('should return title from configured metadata when available', () => { + component.metadataLinkViewPopoverData = { + entityDataConfig: [ + { + entityType: 'Publication', + metadataList: ['dc.title', 'dc.identifier.uri'], + titleMetadataList: ['dc.title', 'dc.identifier.uri'], + }, + ], + fallbackMetdataList: [], + }; + + const title = component.getTitleFromMetadataList(); + expect(title).toBe('Test Title, http://example.com'); + }); + + it('should fallback to defaultTitleMetadataList when no configured title is present', () => { + component.metadataLinkViewPopoverData = { + entityDataConfig: [ + { + entityType: 'Publication', + metadataList: ['dc.title', 'dc.identifier.uri'], + titleMetadataList: ['dc.nonexistent'], + }, + ], + fallbackMetdataList: [], + }; + + const title = component.getTitleFromMetadataList(); + expect(title).toBe('Test Title'); + }); + }); + +}); diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.ts b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.ts new file mode 100644 index 00000000000..924c419b99d --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.ts @@ -0,0 +1,142 @@ +import { + AsyncPipe, + NgOptimizedImage, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { IdentifierSubtypesConfig } from '@dspace/config/identifier-subtypes-config.interface'; +import { MetadataLinkViewPopoverDataConfig } from '@dspace/config/metadata-link-view-popoverdata-config.interface'; +import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; +import { Item } from '@dspace/core/shared/item.model'; +import { + hasNoValue, + hasValue, +} from '@dspace/shared/utils/empty.util'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthorithyIcon } from 'src/config/submission-config.interface'; +import { environment } from 'src/environments/environment'; + +import { VarDirective } from '../../utils/var.directive'; +import { MetadataLinkViewAvatarPopoverComponent } from '../metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component'; +import { MetadataLinkViewOrcidComponent } from '../metadata-link-view-orcid/metadata-link-view-orcid.component'; + + +@Component({ + selector: 'ds-metadata-link-view-popover', + templateUrl: './metadata-link-view-popover.component.html', + styleUrls: ['./metadata-link-view-popover.component.scss'], + imports: [ + AsyncPipe, + MetadataLinkViewAvatarPopoverComponent, + MetadataLinkViewOrcidComponent, + NgbTooltipModule, + NgOptimizedImage, + RouterLink, + TranslateModule, + VarDirective, + ], +}) +export class MetadataLinkViewPopoverComponent implements OnInit { + + /** + * The item to display the metadata for + */ + @Input() item: Item; + + /** + * The metadata link view popover data configuration. + * This configuration is used to determine which metadata fields to display for the given entity type + */ + metadataLinkViewPopoverData: MetadataLinkViewPopoverDataConfig = environment.metadataLinkViewPopoverData; + + /** + * The metadata fields to display for the given entity type + */ + entityMetdataFields: string[] = []; + + /** + * The metadata fields including long text metadata values. + * These metadata values should be truncated to a certain length. + */ + longTextMetadataList = ['dc.description.abstract', 'dc.description']; + + /** + * The source icons configuration + */ + sourceIcons: AuthorithyIcon[] = environment.submission.icons.authority.sourceIcons; + + /** + * The identifier subtype configurations + */ + identifierSubtypeConfig: IdentifierSubtypesConfig[] = environment.identifierSubtypes; + + /** + * Whether the entity type is not found in the metadataLinkViewPopoverData configuration + */ + isOtherEntityType = false; + + /** + * The title to be displayed + */ + title: string; + + private readonly titleSeparator = ', '; + private readonly defaultTitleMetadataList = ['dc.title']; + + /** + * If `metadataLinkViewPopoverData` is provided, it retrieves the metadata fields based on the entity type. + * If no metadata fields are found for the entity type, it falls back to the fallback metadata list. + */ + ngOnInit() { + if (this.metadataLinkViewPopoverData) { + const metadataFields = this.metadataLinkViewPopoverData.entityDataConfig.find((config) => config.entityType === this.item.entityType); + this.entityMetdataFields = hasValue(metadataFields) ? metadataFields.metadataList : this.metadataLinkViewPopoverData.fallbackMetdataList; + this.isOtherEntityType = hasNoValue(metadataFields); + this.title = this.getTitleFromMetadataList(); + } + } + + /** + * Checks if the given metadata value is a valid link. + */ + isLink(metadataValue: string): boolean { + const urlRegex = /^(http|https):\/\/[^ "]+$/; + return urlRegex.test(metadataValue); + } + + /** + * Returns the page route for the item. + * @returns The page route for the item. + */ + getItemPageRoute(): string { + return getItemPageRoute(this.item); + } + + /** + * Retrieves the identifier subtype configuration based on the given metadata value. + * @param metadataValue - The metadata value used to determine the identifier subtype. + * @returns The identifier subtype configuration object. + */ + getSourceSubTypeIdentifier(metadataValue: string): IdentifierSubtypesConfig { + const metadataValueSplited = metadataValue.split('.'); + const subtype = metadataValueSplited[metadataValueSplited.length - 1]; + const identifierSubtype = this.identifierSubtypeConfig.find((config) => config.name === subtype); + return identifierSubtype; + } + + /** + * Generates the title for the popover based on the title metadata list. + * @returns The generated title as a string. + */ + getTitleFromMetadataList(): string { + const titleMetadataList = this.metadataLinkViewPopoverData.entityDataConfig.find((config) => config.entityType === this.item.entityType)?.titleMetadataList; + const itemHasConfiguredTitle = titleMetadataList?.length && titleMetadataList.map(metadata => this.item.firstMetadataValue(metadata)).some(value => hasValue(value)); + return (itemHasConfiguredTitle ? titleMetadataList : this.defaultTitleMetadataList) + .map(metadataField => this.item.firstMetadataValue(metadataField)).join(this.titleSeparator); + } +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.html b/src/app/shared/metadata-link-view/metadata-link-view.component.html new file mode 100644 index 00000000000..abc7a38549e --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view.component.html @@ -0,0 +1,51 @@ +
+ @if (metadataView) { + + } +
+ + + + + {{metadataView.value}} + + + + @if (metadataView.orcidAuthenticated) { + orcid-logo + } + + + + {{normalizeValue(metadataView.value)}} + + + + {{normalizeValue(metadataView.value)}} + + + + + + diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.scss b/src/app/shared/metadata-link-view/metadata-link-view.component.scss new file mode 100644 index 00000000000..f34f101c7e0 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view.component.scss @@ -0,0 +1,11 @@ +.orcid-icon { + height: 1.2rem; + padding-left: 0.3rem; +} + + +::ng-deep .popover { + max-width: 400px !important; + width: 100%; + min-width: 300px !important; +} diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view.component.spec.ts new file mode 100644 index 00000000000..789730fca64 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view.component.spec.ts @@ -0,0 +1,217 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; + +import { ItemDataService } from '../../core/data/item-data.service'; +import { Item } from '../../core/shared/item.model'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { EntityIconDirective } from '../entity-icon/entity-icon.directive'; +import { VarDirective } from '../utils/var.directive'; +import { MetadataLinkViewComponent } from './metadata-link-view.component'; +import SpyObj = jasmine.SpyObj; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '@dspace/core/utilities/remote-data.utils'; + +import { MetadataLinkViewPopoverComponent } from './metadata-link-view-popover/metadata-link-view-popover.component'; + +describe('MetadataLinkViewComponent', () => { + let component: MetadataLinkViewComponent; + let fixture: ComponentFixture; + let itemService: SpyObj; + const validAuthority = uuidv4(); + + const testPerson = Object.assign(new Item(), { + id: '1', + bundles: of({}), + metadata: { + 'dspace.entity.type': [ + Object.assign(new MetadataValue(), { + value: 'Person', + }), + ], + 'person.orgunit.id': [ + Object.assign(new MetadataValue(), { + value: 'OrgUnit', + authority: '2', + }), + ], + 'person.identifier.orcid': [ + Object.assign(new MetadataValue(), { + language: 'en_US', + value: '0000-0001-8918-3592', + }), + ], + 'dspace.orcid.authenticated': [ + Object.assign(new MetadataValue(), { + language: null, + value: 'authenticated', + }), + ], + }, + entityType: 'Person', + }); + + const testOrgunit = Object.assign(new Item(), { + id: '2', + bundles: of({}), + metadata: { + 'dspace.entity.type': [ + Object.assign(new MetadataValue(), { + value: 'OrgUnit', + }), + ], + 'orgunit.person.id': [ + Object.assign(new MetadataValue(), { + value: 'Person', + authority: '1', + }), + ], + }, + entityType: 'OrgUnit', + }); + + const testMetadataValueWithoutAuthority = Object.assign(new MetadataValue(), { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '56e99d82-2cae-4cce-8d12-39899dea7c72', + value: 'Università degli Studi di Milano Bicocca', + }); + + const testMetadataValueWithAuthority = Object.assign(new MetadataValue(), { + authority: validAuthority, + confidence: 600, + language: null, + place: 0, + uuid: '56e99d82-2cae-4cce-8d12-39899dea7c72', + value: 'Università degli Studi di Milano Bicocca', + }); + + itemService = jasmine.createSpyObj('ItemDataService', { + findById: jasmine.createSpy('findById'), + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NgbTooltipModule, + RouterTestingModule, + MetadataLinkViewComponent, EntityIconDirective, VarDirective, + ], + providers: [ + { provide: ItemDataService, useValue: itemService }, + ], + }) + .overrideComponent(MetadataLinkViewComponent, { remove: { imports: [MetadataLinkViewPopoverComponent] } }).compileComponents(); + })); + + describe('Check metadata without authority', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MetadataLinkViewComponent); + itemService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testOrgunit)); + component = fixture.componentInstance; + component.metadata = testMetadataValueWithoutAuthority; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the span element', () => { + const text = fixture.debugElement.query(By.css('[data-test="textWithoutIcon"]')); + const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]')); + + expect(text).toBeTruthy(); + expect(link).toBeNull(); + }); + + }); + + describe('Check metadata with authority', () => { + describe('when item is found with orcid', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MetadataLinkViewComponent); + itemService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testPerson)); + component = fixture.componentInstance; + component.metadata = testMetadataValueWithAuthority; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the link element', () => { + const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]')); + + expect(link).toBeTruthy(); + }); + + it('should render the orcid icon', () => { + const icon = fixture.debugElement.query(By.css('[data-test="orcidIcon"]')); + + expect(icon).toBeTruthy(); + }); + }); + + describe('when item is found without orcid', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MetadataLinkViewComponent); + itemService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testOrgunit)); + component = fixture.componentInstance; + component.metadata = testMetadataValueWithAuthority; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the link element', () => { + const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]')); + + expect(link).toBeTruthy(); + }); + + it('should not render the orcid icon', () => { + const icon = fixture.debugElement.query(By.css('[data-test="orcidIcon"]')); + + expect(icon).toBeFalsy(); + }); + }); + + describe('when item is not found', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MetadataLinkViewComponent); + itemService.findById.and.returnValue(createFailedRemoteDataObject$()); + component = fixture.componentInstance; + component.metadata = testMetadataValueWithAuthority; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the span element', () => { + const text = fixture.debugElement.query(By.css('[data-test="textWithIcon"]')); + const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]')); + + expect(text).toBeTruthy(); + expect(link).toBeNull(); + }); + }); + }); + +}); diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.ts b/src/app/shared/metadata-link-view/metadata-link-view.component.ts new file mode 100644 index 00000000000..0c4d7f5fd55 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view.component.ts @@ -0,0 +1,182 @@ +import { + AsyncPipe, + NgTemplateOutlet, +} from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; +import { followLink } from '@dspace/core/shared/follow-link-config.model'; +import { PLACEHOLDER_PARENT_METADATA } from '@dspace/core/shared/form/ds-dynamic-form-constants'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { + NgbPopoverModule, + NgbTooltipModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { + Observable, + of, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { ItemDataService } from '../../core/data/item-data.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { Metadata } from '../../core/shared/metadata.utils'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { EntityIconDirective } from '../entity-icon/entity-icon.directive'; +import { VarDirective } from '../utils/var.directive'; +import { MetadataLinkViewPopoverComponent } from './metadata-link-view-popover/metadata-link-view-popover.component'; +import { MetadataView } from './metadata-view.model'; +import { StickyPopoverDirective } from './sticky-popover.directive'; + +@Component({ + selector: 'ds-metadata-link-view', + templateUrl: './metadata-link-view.component.html', + styleUrls: ['./metadata-link-view.component.scss'], + imports: [ + AsyncPipe, + EntityIconDirective, + MetadataLinkViewPopoverComponent, + NgbPopoverModule, + NgbTooltipModule, + NgTemplateOutlet, + RouterLink, + StickyPopoverDirective, + VarDirective, + ], +}) +export class MetadataLinkViewComponent implements OnInit { + + /** + * Metadata value that we need to show in the template + */ + @Input() metadata: MetadataValue; + + /** + * Processed metadata to create MetadataOrcid with the information needed to show + */ + metadataView$: Observable; + + /** + * Position of the Icon before/after the element + */ + iconPosition = 'after'; + + /** + * Related item of the metadata value + */ + relatedItem: Item; + + /** + * Route of related item page + */ + relatedDsoRoute: string; + + /** + * Map all entities with the icons specified in the environment configuration file + */ + constructor(private itemService: ItemDataService) { } + + /** + * On init process metadata to get the information and form MetadataOrcid model + */ + ngOnInit(): void { + this.metadataView$ = of(this.metadata).pipe( + switchMap((metadataValue: MetadataValue) => this.getMetadataView(metadataValue)), + take(1), + ); + } + + + /** + * Retrieves the metadata view for a given metadata value. + * If the metadata value has a valid authority, it retrieves the item using the authority and creates a metadata view. + * If the metadata value does not have a valid authority, it creates a metadata view with null values. + * + * @param metadataValue The metadata value for which to retrieve the metadata view. + * @returns An Observable that emits the metadata view. + */ + private getMetadataView(metadataValue: MetadataValue): Observable { + const linksToFollow = [followLink('thumbnail')]; + + if (Metadata.hasValidAuthority(metadataValue.authority)) { + return this.itemService.findById(metadataValue.authority, true, false, ...linksToFollow).pipe( + getFirstCompletedRemoteData(), + map((itemRD: RemoteData) => this.createMetadataView(itemRD, metadataValue)), + ); + } else { + return of({ + authority: null, + value: metadataValue.value, + orcidAuthenticated: null, + entityType: null, + entityStyle: null, + }); + } + } + + /** + * Creates a MetadataView object based on the provided itemRD and metadataValue. + * @param itemRD - The RemoteData object containing the item information. + * @param metadataValue - The MetadataValue object containing the metadata information. + * @returns The created MetadataView object. + */ + private createMetadataView(itemRD: RemoteData, metadataValue: MetadataValue): MetadataView { + if (itemRD.hasSucceeded && itemRD.payload) { + this.relatedItem = itemRD.payload; + this.relatedDsoRoute = this.getItemPageRoute(this.relatedItem); + return { + authority: metadataValue.authority, + value: metadataValue.value, + orcidAuthenticated: this.getOrcid(itemRD.payload), + entityType: (itemRD.payload as Item)?.entityType, + }; + } else { + return { + authority: null, + value: metadataValue.value, + orcidAuthenticated: null, + entityType: 'PRIVATE', + }; + } + } + + /** + * Returns the orcid for given item, or null if there is no metadata authenticated for person + * + * @param referencedItem Item of the metadata being shown + */ + getOrcid(referencedItem: Item): string { + if (referencedItem?.hasMetadata('dspace.orcid.authenticated')) { + return referencedItem.firstMetadataValue('person.identifier.orcid'); + } + return null; + } + + /** + * Normalize value to display + * + * @param value + */ + normalizeValue(value: string): string { + if (isNotEmpty(value) && value.includes(PLACEHOLDER_PARENT_METADATA)) { + return ''; + } else { + return value; + } + } + + getItemPageRoute(item: Item): string { + return getItemPageRoute(item); + } + +} diff --git a/src/app/shared/metadata-link-view/metadata-view.model.ts b/src/app/shared/metadata-link-view/metadata-view.model.ts new file mode 100644 index 00000000000..fc5ecf24792 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-view.model.ts @@ -0,0 +1,6 @@ +export interface MetadataView { + authority: string; + value: string; + orcidAuthenticated: string; + entityType: string; +} diff --git a/src/app/shared/metadata-link-view/sticky-popover.directive.ts b/src/app/shared/metadata-link-view/sticky-popover.directive.ts new file mode 100644 index 00000000000..0809fde9a97 --- /dev/null +++ b/src/app/shared/metadata-link-view/sticky-popover.directive.ts @@ -0,0 +1,129 @@ +import { DOCUMENT } from '@angular/common'; +import { + ApplicationRef, + ChangeDetectorRef, + Directive, + ElementRef, + Inject, + Injector, + Input, + NgZone, + OnDestroy, + OnInit, + Renderer2, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { + NavigationStart, + Router, +} from '@angular/router'; +import { + NgbPopover, + NgbPopoverConfig, +} from '@ng-bootstrap/ng-bootstrap'; +import { Subscription } from 'rxjs'; + +/** + * Directive to create a sticky popover using NgbPopover. + * The popover remains open when the mouse is over its content and closes when the mouse leaves. + */ +@Directive({ + selector: '[dsStickyPopover]', + standalone:true, +}) +export class StickyPopoverDirective extends NgbPopover implements OnInit, OnDestroy { + /** Template for the sticky popover content */ + @Input() dsStickyPopover: TemplateRef; + + /** Subscriptions to manage router events */ + subs: Subscription[] = []; + + /** Flag to determine if the popover can be closed */ + private canClosePopover: boolean; + + /** Reference to the element the directive is applied to */ + private readonly _elRef; + + /** Renderer to listen to and manipulate DOM elements */ + private readonly _render; + + constructor( + _elementRef: ElementRef, + _renderer: Renderer2, injector: Injector, + viewContainerRef: ViewContainerRef, + config: NgbPopoverConfig, + _ngZone: NgZone, + @Inject(DOCUMENT) _document: Document, + _changeDetector: ChangeDetectorRef, + applicationRef: ApplicationRef, + private router: Router, + ) { + super(_elementRef, _renderer, injector, viewContainerRef, config, _ngZone, document, _changeDetector, applicationRef); + this._elRef = _elementRef; + this._render = _renderer; + this.triggers = 'manual'; + this.container = 'body'; + } + + /** + * Sets up event listeners for mouse enter, mouse leave, and click events. + */ + ngOnInit(): void { + super.ngOnInit(); + this.ngbPopover = this.dsStickyPopover; + + this._render.listen(this._elRef.nativeElement, 'mouseenter', () => { + this.canClosePopover = true; + this.open(); + }); + + this._render.listen(this._elRef.nativeElement, 'mouseleave', () => { + setTimeout(() => { + if (this.canClosePopover) { + this.close(); + } + }, 100); + }); + + this._render.listen(this._elRef.nativeElement, 'click', () => { + this.close(); + }); + + this.subs.push( + this.router.events.subscribe((event) => { + if (event instanceof NavigationStart) { + this.close(); + } + }), + ); + } + + /** + * Opens the popover and sets up event listeners for mouse over and mouse out events on the popover. + */ + open() { + super.open(); + const popover = window.document.querySelector('.popover'); + this._render.listen(popover, 'mouseover', () => { + this.canClosePopover = false; + }); + + this._render.listen(popover, 'mouseout', () => { + this.canClosePopover = true; + setTimeout(() => { + if (this.canClosePopover) { + this.close(); + } + }, 0); + }); + } + + /** + * Unsubscribes from all subscriptions when the directive is destroyed. + */ + ngOnDestroy() { + super.ngOnDestroy(); + this.subs.forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/shared/metadata-representation/metadata-representation.decorator.ts b/src/app/shared/metadata-representation/metadata-representation.decorator.ts index 5fc916daef5..e7cb2a720bd 100644 --- a/src/app/shared/metadata-representation/metadata-representation.decorator.ts +++ b/src/app/shared/metadata-representation/metadata-representation.decorator.ts @@ -15,6 +15,7 @@ import { DEFAULT_THEME, resolveTheme, } from '../object-collection/shared/listable-object/listable-object.decorator'; +import { AuthorityLinkMetadataListElementComponent } from '../object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component'; import { BrowseLinkMetadataListElementComponent } from '../object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component'; import { ItemMetadataListElementComponent } from '../object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; import { PlainTextMetadataListElementComponent } from '../object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; @@ -34,7 +35,8 @@ export type MetadataRepresentationComponent = typeof ItemMetadataListElementComponent | typeof OrgUnitItemMetadataListElementComponent | typeof PersonItemMetadataListElementComponent | - typeof ProjectItemMetadataListElementComponent; + typeof ProjectItemMetadataListElementComponent | + typeof AuthorityLinkMetadataListElementComponent; export const METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP = new Map>>>([ @@ -42,21 +44,27 @@ export const METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP = [MetadataRepresentationType.PlainText, new Map([ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, PlainTextMetadataListElementComponent as any]])]])], [MetadataRepresentationType.AuthorityControlled, new Map([ - [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, PlainTextMetadataListElementComponent]])]])], + [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent]])]])], [MetadataRepresentationType.BrowseLink, new Map([ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, BrowseLinkMetadataListElementComponent]])]])], [MetadataRepresentationType.Item, new Map([ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, ItemMetadataListElementComponent]])]])], ])], ['Person', new Map([ + [MetadataRepresentationType.AuthorityControlled, new Map([ + [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent as any]])]])], [MetadataRepresentationType.Item, new Map([ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, PersonItemMetadataListElementComponent]])]])], ])], ['OrgUnit', new Map([ + [MetadataRepresentationType.AuthorityControlled, new Map([ + [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent as any]])]])], [MetadataRepresentationType.Item, new Map([ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, OrgUnitItemMetadataListElementComponent]])]])], ])], ['Project', new Map([ + [MetadataRepresentationType.AuthorityControlled, new Map([ + [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent as any]])]])], [MetadataRepresentationType.Item, new Map([ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, ProjectItemMetadataListElementComponent]])]])], ])], diff --git a/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts index 2eba47d4fb7..1140df6df2a 100644 --- a/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts +++ b/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts @@ -10,6 +10,7 @@ import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { AuthService } from '@dspace/core/auth/auth.service'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; import { Item } from '@dspace/core/shared/item.model'; import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub'; @@ -17,6 +18,7 @@ import { AuthorizationDataServiceStub } from '@dspace/core/testing/authorization import { DSONameServiceMock } from '@dspace/core/testing/dso-name.service.mock'; import { TruncatableServiceStub } from '@dspace/core/testing/truncatable-service.stub'; import { XSRFService } from '@dspace/core/xsrf/xsrf.service'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; @@ -85,8 +87,6 @@ describe('ItemListElementComponent', () => { TranslateModule.forRoot(), TruncatePipe, ], - declarations: [ - ], providers: [ { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: APP_CONFIG, useValue: environment }, @@ -96,6 +96,8 @@ describe('ItemListElementComponent', () => { { provide: ThemeService, useValue: themeService }, { provide: TruncatableService, useValue: truncatableService }, { provide: XSRFService, useValue: {} }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + provideMockStore(), ], }).overrideComponent(ItemListElementComponent, { set: { changeDetection: ChangeDetectionStrategy.Default }, diff --git a/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.html new file mode 100644 index 00000000000..847d69e8e11 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.spec.ts new file mode 100644 index 00000000000..5477752846c --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.spec.ts @@ -0,0 +1,65 @@ +import { + ChangeDetectionStrategy, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ItemDataService } from '@dspace/core/data/item-data.service'; +import { MetadataRepresentationType } from '@dspace/core/shared/metadata-representation/metadata-representation.model'; +import { MetadatumRepresentation } from '@dspace/core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { ValueListBrowseDefinition } from '@dspace/core/shared/value-list-browse-definition.model'; + +import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component'; +import { AuthorityLinkMetadataListElementComponent } from './authority-link-metadata-list-element.component'; + + +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'Test Author', + browseDefinition: Object.assign(new ValueListBrowseDefinition(), { + id: 'author', + }), +} as Partial); + +const itemService = jasmine.createSpyObj('ItemDataService', { + findByIdWithProjections: jasmine.createSpy('findByIdWithProjections'), +}); + +describe('AuthorityLinkMetadataListElementComponent', () => { + let comp: AuthorityLinkMetadataListElementComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + void TestBed.configureTestingModule({ + imports: [AuthorityLinkMetadataListElementComponent, MetadataLinkViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: ItemDataService, useValue: itemService }, + ], + }).overrideComponent(AuthorityLinkMetadataListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuthorityLinkMetadataListElementComponent); + comp = fixture.componentInstance; + }); + + describe('with authorithy controlled metadata', () => { + beforeEach(() => { + comp.mdRepresentation = mockMetadataRepresentation; + spyOnProperty(comp.mdRepresentation, 'representationType', 'get').and.returnValue(MetadataRepresentationType.AuthorityControlled); + fixture.detectChanges(); + }); + + it('should contain the value', () => { + expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); + }); + + }); + +}); diff --git a/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.ts new file mode 100644 index 00000000000..6d4bbea52c8 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.ts @@ -0,0 +1,29 @@ + +import { + Component, + OnInit, +} from '@angular/core'; +import { MetadatumRepresentation } from '@dspace/core/shared/metadata-representation/metadatum/metadatum-representation.model'; + +import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component'; +import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; + +@Component({ + selector: 'ds-authority-link-metadata-list-element', + templateUrl: './authority-link-metadata-list-element.component.html', + imports: [ + MetadataLinkViewComponent, + ], +}) +/** + * A component for displaying MetadataRepresentation objects with authority in the form of a link + * It will simply use the value retrieved from MetadataRepresentation.getValue() to display a link to the item + */ +export class AuthorityLinkMetadataListElementComponent extends MetadataRepresentationListElementComponent implements OnInit { + + metadataValue: MetadatumRepresentation; + + ngOnInit() { + this.metadataValue = this.mdRepresentation as MetadatumRepresentation; + } +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index 2c9c7e44bd4..7d8c407e741 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -30,10 +30,9 @@

+ @for (author of authorMetadataList; track author.uuid; let last = $last) { + + @if (!last) { ; } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts index bc8d916f7f8..1e16028c1a7 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts @@ -19,6 +19,7 @@ import { import { of } from 'rxjs'; import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; +import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component'; import { ThemedBadgesComponent } from '../../../object-collection/shared/badges/themed-badges.component'; import { ItemCollectionComponent } from '../../../object-collection/shared/mydspace-item-collection/item-collection.component'; import { ItemSubmitterComponent } from '../../../object-collection/shared/mydspace-item-submitter/item-submitter.component'; @@ -119,6 +120,7 @@ describe('ItemListPreviewComponent', () => { ThemedThumbnailComponent, ThemedBadgesComponent, TruncatableComponent, TruncatablePartComponent, ItemSubmitterComponent, ItemCollectionComponent, + MetadataLinkViewComponent, ], }, }).compileComponents(); @@ -127,18 +129,12 @@ describe('ItemListPreviewComponent', () => { beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(ItemListPreviewComponent); component = fixture.componentInstance; - - })); - - beforeEach(() => { component.object = { hitHighlights: {} } as any; - }); + component.item = mockItemWithAuthorAndDate; + fixture.detectChanges(); + })); describe('When showThumbnails is true', () => { - beforeEach(() => { - component.item = mockItemWithAuthorAndDate; - fixture.detectChanges(); - }); it('should add the thumbnail element', () => { const thumbnail = fixture.debugElement.query(By.css('ds-thumbnail')); expect(thumbnail).toBeTruthy(); @@ -146,35 +142,24 @@ describe('ItemListPreviewComponent', () => { }); describe('When the item has an author', () => { - beforeEach(() => { - component.item = mockItemWithAuthorAndDate; - fixture.detectChanges(); - }); - it('should show the author paragraph', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); + const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors ds-metadata-link-view')); expect(itemAuthorField).not.toBeNull(); }); }); describe('When the item has no author', () => { - beforeEach(() => { + beforeEach(waitForAsync(() => { component.item = mockItemWithoutAuthorAndDate; fixture.detectChanges(); - }); - + })); it('should not show the author paragraph', () => { - const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors')); + const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors ds-metadata-link-view')); expect(itemAuthorField).toBeNull(); }); }); describe('When the item has an issuedate', () => { - beforeEach(() => { - component.item = mockItemWithAuthorAndDate; - fixture.detectChanges(); - }); - it('should show the issuedate span', () => { const dateField = fixture.debugElement.query(By.css('span.item-list-date')); expect(dateField).not.toBeNull(); @@ -182,11 +167,6 @@ describe('ItemListPreviewComponent', () => { }); describe('When the item has no issuedate', () => { - beforeEach(() => { - component.item = mockItemWithoutAuthorAndDate; - fixture.detectChanges(); - }); - it('should show the issuedate empty placeholder', () => { const dateField = fixture.debugElement.query(By.css('span.item-list-date')); expect(dateField).not.toBeNull(); @@ -205,54 +185,3 @@ describe('ItemListPreviewComponent', () => { }); }); }); - -describe('ItemListPreviewComponent', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock, - }, - }), - NoopAnimationsModule, - ItemListPreviewComponent, TruncatePipe, - ], - providers: [ - { provide: 'objectElementProvider', useValue: { mockItemWithAuthorAndDate } }, - { provide: APP_CONFIG, useValue: enviromentNoThumbs }, - ], - schemas: [NO_ERRORS_SCHEMA], - }).overrideComponent(ItemListPreviewComponent, { - add: { changeDetection: ChangeDetectionStrategy.Default }, - remove: { - imports: [ - ThemedThumbnailComponent, ThemedBadgesComponent, - TruncatableComponent, TruncatablePartComponent, - ItemSubmitterComponent, ItemCollectionComponent, - ], - }, - }).compileComponents(); - })); - beforeEach(waitForAsync(() => { - fixture = TestBed.createComponent(ItemListPreviewComponent); - component = fixture.componentInstance; - - })); - - beforeEach(() => { - component.object = { hitHighlights: {} } as any; - }); - - describe('When showThumbnails is true', () => { - beforeEach(() => { - component.item = mockItemWithAuthorAndDate; - fixture.detectChanges(); - }); - it('should add the thumbnail element', () => { - const thumbnail = fixture.debugElement.query(By.css('ds-thumbnail')); - expect(thumbnail).toBeFalsy(); - }); - }); -}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index 0a60c7aa878..fab8d15cf98 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -15,12 +15,14 @@ import { import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { Context } from '@dspace/core/shared/context.model'; import { Item } from '@dspace/core/shared/item.model'; +import { MetadataValue } from '@dspace/core/shared/metadata.models'; import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; import { WorkflowItem } from '@dspace/core/submission/models/workflowitem.model'; import { TranslateModule } from '@ngx-translate/core'; import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; import { fadeInOut } from '../../../animations/fade'; +import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component'; import { ThemedBadgesComponent } from '../../../object-collection/shared/badges/themed-badges.component'; import { ItemCollectionComponent } from '../../../object-collection/shared/mydspace-item-collection/item-collection.component'; import { ItemSubmitterComponent } from '../../../object-collection/shared/mydspace-item-submitter/item-submitter.component'; @@ -39,6 +41,7 @@ import { TruncatablePartComponent } from '../../../truncatable/truncatable-part/ AsyncPipe, ItemCollectionComponent, ItemSubmitterComponent, + MetadataLinkViewComponent, NgClass, ThemedBadgesComponent, ThemedThumbnailComponent, @@ -81,6 +84,8 @@ export class ItemListPreviewComponent implements OnInit { dsoTitle: string; + authorMetadataList: MetadataValue[] = []; + constructor( @Inject(APP_CONFIG) protected appConfig: AppConfig, public dsoNameService: DSONameService, @@ -90,6 +95,7 @@ export class ItemListPreviewComponent implements OnInit { ngOnInit(): void { this.showThumbnails = this.appConfig.browseBy.showThumbnails; this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.item, true); + this.authorMetadataList = this.item.allMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], undefined, true); } diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html index ba12d01189a..3a2f5ed03af 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html @@ -23,45 +23,60 @@ }

- @if (object !== undefined && object !== null) { - - @if (linkType !== linkTypes.None) { - - } - @if (linkType === linkTypes.None) { - - } - - - @if (firstMetadataValue('dc.publisher') || firstMetadataValue('dc.date.issued')) { - (@if (firstMetadataValue('dc.publisher')) { - - } - @if (firstMetadataValue('dc.publisher') && firstMetadataValue('dc.date.issued')) { - , - } - @if (firstMetadataValue('dc.date.issued')) { - - }) - } - @if (allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']).length > 0) { - - @for (author of allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']); track author; let last = $last) { - - - @if (!last) { - ; + @if (object !== undefined && object !== null) { + + @if (linkType !== linkTypes.None) { + + } + @if (linkType === linkTypes.None) { + + } + + + @if (firstMetadataValue('dc.publisher') || firstMetadataValue('dc.date.issued')) { + (@if (firstMetadataValue('dc.publisher')) { + + } + @if (firstMetadataValue('dc.publisher') && firstMetadataValue('dc.date.issued')) { + , + } + @if (firstMetadataValue('dc.date.issued')) { + + }) + } + @if (dso.allMetadataValues(authorMetadata, placeholderFilter).length > 0) { + + @let collapsed = isCollapsed() | async; + + @if (collapsed) { + @for (author of dso.limitedMetadata(authorMetadata, additionalMetadataLimit, placeholderFilter); track author; let last = $last) { + + + @if (!last) { + ; + } + + } + } + @if (!collapsed) { + @for (author of dso.allMetadata(authorMetadata, placeholderFilter); track author; let last = $last) { + + + @if (!last) { + ; + } + } - } - - } - - - @if (firstMetadataValue('dc.description.abstract'); as abstract) { +
+ + } + + + @if (firstMetadataValue('dc.description.abstract'); as abstract) {
@@ -69,6 +84,6 @@
} - } -
+ } +
diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts index 242f4535a1a..2707d3b5339 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.spec.ts @@ -13,6 +13,7 @@ import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { AuthService } from '@dspace/core/auth/auth.service'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; import { Item } from '@dspace/core/shared/item.model'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; @@ -24,6 +25,10 @@ import { import { mockTruncatableService } from '@dspace/core/testing/mock-trucatable.service'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; +import { MetadataLinkViewComponent } from 'src/app/shared/metadata-link-view/metadata-link-view.component'; +import { TruncatableComponent } from 'src/app/shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from 'src/app/shared/truncatable/truncatable-part/truncatable-part.component'; +import { ThemedThumbnailComponent } from 'src/app/thumbnail/themed-thumbnail.component'; import { getMockThemeService } from '../../../../../theme-support/test/theme-service.mock'; import { ThemeService } from '../../../../../theme-support/theme.service'; @@ -225,10 +230,18 @@ describe('ItemSearchResultListElementComponent', () => { 'invalidateAuthorizationsRequestCache', ]), }, + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemSearchResultListElementComponent, { add: { changeDetection: ChangeDetectionStrategy.Default }, + }).overrideComponent(ItemSearchResultListElementComponent, { + remove: { imports: [ + ThemedThumbnailComponent, + TruncatableComponent, + TruncatablePartComponent, + MetadataLinkViewComponent, + ] }, }).compileComponents(); })); @@ -277,6 +290,32 @@ describe('ItemSearchResultListElementComponent', () => { }); }); + describe('When the item has authors and isCollapsed is true', () => { + beforeEach(() => { + spyOn(publicationListElementComponent, 'isCollapsed').and.returnValue(of(true)); + publicationListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show limitedMetadata', () => { + const authorElements = fixture.debugElement.queryAll(By.css('span.item-list-authors ds-metadata-link-view')); + expect(authorElements.length).toBe(mockItemWithMetadata.indexableObject.limitedMetadata(publicationListElementComponent.authorMetadata, publicationListElementComponent.additionalMetadataLimit).length); + }); + }); + + describe('When the item has authors and isCollapsed is false', () => { + beforeEach(() => { + spyOn(publicationListElementComponent, 'isCollapsed').and.returnValue(of(false)); + publicationListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show allMetadata', () => { + const authorElements = fixture.debugElement.queryAll(By.css('span.item-list-authors ds-metadata-link-view')); + expect(authorElements.length).toBe(mockItemWithMetadata.indexableObject.allMetadata(publicationListElementComponent.authorMetadata).length); + }); + }); + describe('When the item has a publisher', () => { beforeEach(() => { publicationListElementComponent.object = mockItemWithMetadata; @@ -413,6 +452,13 @@ describe('ItemSearchResultListElementComponent', () => { schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemSearchResultListElementComponent, { set: { changeDetection: ChangeDetectionStrategy.Default }, + }).overrideComponent(ItemSearchResultListElementComponent, { + remove: { imports: [ + ThemedThumbnailComponent, + TruncatableComponent, + TruncatablePartComponent, + MetadataLinkViewComponent, + ] }, }).compileComponents(); })); diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts index 8b4e3d028c3..ceecab94105 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts @@ -9,10 +9,14 @@ import { import { RouterLink } from '@angular/router'; import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; import { Item } from '@dspace/core/shared/item.model'; +import { MetadataValueFilter } from '@dspace/core/shared/metadata.models'; +import { PLACEHOLDER_VALUE } from '@dspace/core/shared/metadata.utils'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; +import { environment } from '../../../../../../../environments/environment'; import { ThemedThumbnailComponent } from '../../../../../../thumbnail/themed-thumbnail.component'; +import { MetadataLinkViewComponent } from '../../../../../metadata-link-view/metadata-link-view.component'; import { ThemedBadgesComponent } from '../../../../../object-collection/shared/badges/themed-badges.component'; import { listableObjectComponent } from '../../../../../object-collection/shared/listable-object/listable-object.decorator'; import { TruncatableComponent } from '../../../../../truncatable/truncatable.component'; @@ -27,6 +31,7 @@ import { SearchResultListElementComponent } from '../../../search-result-list-el templateUrl: './item-search-result-list-element.component.html', imports: [ AsyncPipe, + MetadataLinkViewComponent, NgClass, RouterLink, ThemedBadgesComponent, @@ -44,6 +49,14 @@ export class ItemSearchResultListElementComponent extends SearchResultListElemen */ itemPageRoute: string; + authorMetadata = environment.searchResult.authorMetadata; + + + readonly placeholderFilter: MetadataValueFilter = { + negate: true, + value: PLACEHOLDER_VALUE, + }; + ngOnInit(): void { super.ngOnInit(); this.showThumbnails = this.showThumbnails ?? this.appConfig.browseBy.showThumbnails; diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index bfb85285442..dcff3a6967b 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -28,6 +28,11 @@ export class SearchResultListElementComponent, K exten dso: K; dsoTitle: string; + /** + * Limit of additional metadata values to show + */ + additionalMetadataLimit: number; + public constructor(protected truncatableService: TruncatableService, public dsoNameService: DSONameService, @Inject(APP_CONFIG) protected appConfig?: AppConfig) { @@ -38,6 +43,7 @@ export class SearchResultListElementComponent, K exten * Retrieve the dso from the search result */ ngOnInit(): void { + this.additionalMetadataLimit = this.appConfig?.followAuthorityMetadataValuesLimit; if (hasValue(this.object)) { this.dso = this.object.indexableObject; this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso, true); diff --git a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts index b9a1385087b..8f4111fb271 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts @@ -5,6 +5,7 @@ import { waitForAsync, } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { LinkService } from '@dspace/core/cache/builders/link.service'; import { ChildHALResource } from '@dspace/core/shared/child-hal-resource.model'; @@ -16,6 +17,7 @@ import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote import { TranslateModule } from '@ngx-translate/core'; import { TruncatableService } from '../../truncatable/truncatable.service'; +import { TruncatablePartComponent } from '../../truncatable/truncatable-part/truncatable-part.component'; import { VarDirective } from '../../utils/var.directive'; export function createSidebarSearchListElementTests( @@ -33,6 +35,12 @@ export function createSidebarSearchListElementTests( let linkService; + const environment = { + browseBy: { + showThumbnails: true, + }, + }; + beforeEach(waitForAsync(() => { linkService = jasmine.createSpyObj('linkService', { resolveLink: Object.assign(new HALResource(), { @@ -44,11 +52,12 @@ export function createSidebarSearchListElementTests( providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: LinkService, useValue: linkService }, + { provide: APP_CONFIG, useValue: environment }, DSONameService, ...extraProviders, ], schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); + }).overrideComponent(componentClass, { remove: { imports: [TruncatablePartComponent] } }).compileComponents(); })); beforeEach(() => { diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts index 647443d3a08..ee8f7344240 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -13,6 +13,7 @@ import { Router } from '@angular/router'; import { RemoteDataBuildService } from '@dspace/core/cache/builders/remote-data-build.service'; import { PageInfo } from '@dspace/core/shared/page-info.model'; import { AppliedFilter } from '@dspace/core/shared/search/models/applied-filter.model'; +import { FacetValue } from '@dspace/core/shared/search/models/facet-value.model'; import { FacetValues } from '@dspace/core/shared/search/models/facet-values.model'; import { FilterType } from '@dspace/core/shared/search/models/filter-type.model'; import { SearchFilterConfig } from '@dspace/core/shared/search/models/search-filter-config.model'; @@ -41,6 +42,8 @@ describe('SearchFacetFilterComponent', () => { const value2 = 'test2'; const value3 = 'another value3'; const value4 = '52d629dc-7d2f-47b9-aa2d-258b92e45ae1'; + const value5 = 'test authority'; + const authority = '52d629dc-7d2f-47b9-aa2d-258b92e45ae1'; const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), { name: filterName1, filterType: FilterType.text, @@ -72,12 +75,40 @@ describe('SearchFacetFilterComponent', () => { label: value4, value: value4, }); + const appliedFilter5: AppliedFilter = Object.assign(new AppliedFilter(), { + filter: filterName1, + operator: 'authority', + label: authority, + value: authority, + }); + const facetValue1: FacetValue = Object.assign(new FacetValue(), { + label: value1, + value: value1, + }); + const facetValue2: FacetValue = Object.assign(new FacetValue(), { + label: value2, + value: value2, + }); + const facetValue3: FacetValue = Object.assign(new FacetValue(), { + label: value3, + value: value3, + }); + const facetValue4: FacetValue = Object.assign(new FacetValue(), { + label: value5, + value: authority, + }); const values: Partial = { appliedFilters: [ appliedFilter1, appliedFilter2, appliedFilter3, ], + page: [ + facetValue1, + facetValue2, + facetValue3, + facetValue4, + ], pageInfo: Object.assign(new PageInfo(), { currentPage: 0, }), @@ -246,4 +277,23 @@ describe('SearchFacetFilterComponent', () => { })); }); }); + + describe('when selected value has an authority', () => { + let selectedValues$: BehaviorSubject; + + beforeEach(() => { + selectedValues$ = new BehaviorSubject([appliedFilter5]); + spyOn(searchService, 'getSelectedValuesForFilter').and.returnValue(selectedValues$); + comp.ngOnInit(); + }); + + it('should updated the label with the one of the related FacetValue', () => { + const expectedValue = Object.assign(appliedFilter5, { + label: facetValue4.label, + }); + expect(comp.selectedAppliedFilters$).toBeObservable(cold('a', { + a: [expectedValue], + })); + }); + }); }); diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index 324cfc46b0c..d1bd15fd914 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -27,17 +27,23 @@ import { SearchOptions } from '@dspace/core/shared/search/models/search-options. import { hasNoValue, hasValue, + isNotEmpty, } from '@dspace/shared/utils/empty.util'; import { BehaviorSubject, + combineLatest, combineLatest as observableCombineLatest, + from, Observable, of, Subscription, } from 'rxjs'; import { distinctUntilChanged, + filter, map, + mergeMap, + reduce, switchMap, take, tap, @@ -46,6 +52,10 @@ import { import { SEARCH_CONFIG_SERVICE } from '../../../../../my-dspace-page/my-dspace-configuration.service'; import { InputSuggestion } from '../../../../input-suggestions/input-suggestions.model'; import { SearchService } from '../../../search.service'; +import { + getFacetValueForType, + stripOperatorFromFilterValue, +} from '../../../search.utils'; import { SearchConfigurationService } from '../../../search-configuration.service'; import { SearchFilterService } from '../../search-filter.service'; @@ -167,7 +177,29 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { this.searchOptions$.subscribe(() => this.updateFilterValueList()), this.retrieveFilterValues().subscribe(), ); - this.selectedAppliedFilters$ = this.searchService.getSelectedValuesForFilter(this.filterConfig.name).pipe( + + this.selectedAppliedFilters$ = combineLatest([ + this.searchService.getSelectedValuesForFilter(this.filterConfig.name), + this.facetValues$.asObservable().pipe( + mergeMap((values: FacetValues[]) => from(values).pipe( + reduce((acc: FacetValue[], value: FacetValues) => acc.concat(value.page), []), + )), + ), + ]).pipe( + filter(([allAppliedFilters, facetValues]: [AppliedFilter[], FacetValue[]]) => isNotEmpty(facetValues)), + map(([allAppliedFilters, facetValues]) => + allAppliedFilters.map((appliedValue) => { + const fValue = facetValues.find((facetValue: FacetValue) => { + const valueForType = getFacetValueForType(facetValue, this.filterConfig); + return hasValue(valueForType) && + stripOperatorFromFilterValue(valueForType) === appliedValue.value; + }); + + return hasValue(fValue) + ? Object.assign(appliedValue, { label: fValue.label }) + : appliedValue; + }), + ), map((allAppliedFilters: AppliedFilter[]) => allAppliedFilters.filter((appliedFilter: AppliedFilter) => FACET_OPERATORS.includes(appliedFilter.operator))), distinctUntilChanged((previous: AppliedFilter[], next: AppliedFilter[]) => JSON.stringify(previous) === JSON.stringify(next)), ); diff --git a/src/app/shared/search/search-labels/search-labels.component.html b/src/app/shared/search/search-labels/search-labels.component.html index de8e59de78e..c7f70e0e110 100644 --- a/src/app/shared/search/search-labels/search-labels.component.html +++ b/src/app/shared/search/search-labels/search-labels.component.html @@ -1,5 +1,5 @@
- @for (appliedFilter of appliedFilters$ | async; track appliedFilter) { + @for (appliedFilter of appliedFilters$ | async; track appliedFilter.value) { diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index ded2c60e1ef..5d89079dfa1 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -147,7 +147,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { ngOnInit() { this.collectionModifiable = this.route.snapshot.data?.collectionModifiable ?? null; - + // TODO: add all languages projection this.subs.push( this.route.paramMap.pipe( switchMap((params: ParamMap) => this.submissionService.retrieveSubmission(params.get('id'))), diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index af302478ef6..86e0f28c3f5 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -359,6 +359,7 @@ export class InitSubmissionFormAction implements Action { * the submission's sections definition * @param sections * the submission's sections + * @param item * @param errors * the submission's sections errors */ @@ -570,6 +571,7 @@ export class ResetSubmissionFormAction implements Action { * the submission's sections * @param submissionDefinition * the submission's form definition + * @param item */ constructor(collectionId: string, submissionId: string, selfUrl: string, sections: WorkspaceitemSectionsObject, submissionDefinition: SubmissionDefinitionsModel, item: Item) { this.payload = { collectionId, submissionId, selfUrl, sections, submissionDefinition, item }; diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index e5feed170ca..ed37c02091d 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -745,7 +745,7 @@ describe('SectionFormOperationsService test suite', () => { serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlChangeEvent, previousValue); - expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('path'), ['testMapNew'], true); + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('path'), ['testMapNew'], true, null, undefined); }); it('should dispatch a json-path add operation when a map entry has changed', () => { @@ -758,7 +758,7 @@ describe('SectionFormOperationsService test suite', () => { serviceAsAny.dispatchOperationsFromMap(valueMap, pathCombiner, dynamicFormControlChangeEvent, previousValue); - expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('path'), ['testMapNew'], true); + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith(pathCombiner.getPath('path'), ['testMapNew'], true, false, undefined); }); it('should dispatch a json-path remove operation when a map entry has changed', () => { diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index b209f719786..fd9019a3dbd 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -64,17 +64,19 @@ export class SectionFormOperationsService { * the [[FormFieldPreviousValueObject]] for the specified operation * @param hasStoredValue * representing if field value related to the specified operation has stored value + * @param languageMap */ public dispatchOperationsFromEvent(pathCombiner: JsonPatchOperationPathCombiner, event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject, - hasStoredValue: boolean): void { + hasStoredValue: boolean, + languageMap: Map = null): void { switch (event.type) { case 'remove': this.dispatchOperationsFromRemoveEvent(pathCombiner, event, previousValue); break; case 'change': - this.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, hasStoredValue); + this.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, hasStoredValue, languageMap); break; case 'move': this.dispatchOperationsFromMoveEvent(pathCombiner, event, previousValue); @@ -315,7 +317,7 @@ export class SectionFormOperationsService { } else if (event.context && event.context instanceof DynamicFormArrayGroupModel) { // Model is a DynamicRowArrayModel this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); - } else if ((isNotEmpty(value) && typeof value === 'string') || (isNotEmpty(value) && value instanceof FormFieldMetadataValueObject && value.hasValue())) { + } else if ((isNotEmpty(value) && typeof value === 'string') || (isNotEmpty(value) && (value instanceof FormFieldMetadataValueObject || value instanceof VocabularyEntry) && value.hasValue())) { this.operationsBuilder.remove(pathCombiner.getPath(path)); } } @@ -367,12 +369,13 @@ export class SectionFormOperationsService { * the [[FormFieldPreviousValueObject]] for the specified operation * @param hasStoredValue * representing if field value related to the specified operation has stored value + * @param languageMap */ protected dispatchOperationsFromChangeEvent(pathCombiner: JsonPatchOperationPathCombiner, event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject, - hasStoredValue: boolean): void { - + hasStoredValue: boolean, + languageMap?: Map): void { if (event.context && event.context instanceof DynamicFormArrayGroupModel) { // Model is a DynamicRowArrayModel this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); @@ -386,7 +389,7 @@ export class SectionFormOperationsService { if (this.formBuilder.isQualdropGroup(event.model.parent as DynamicFormControlModel) || this.formBuilder.isQualdropGroup(event.model as DynamicFormControlModel)) { // It's a qualdrup model - this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue); + this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue, languageMap); } else if (this.formBuilder.isRelationGroup(event.model)) { // It's a relation model this.dispatchOperationsFromMap(this.getValueMap(value), pathCombiner, event, previousValue); @@ -455,11 +458,13 @@ export class SectionFormOperationsService { * the [[DynamicFormControlEvent]] for the specified operation * @param previousValue * the [[FormFieldPreviousValueObject]] for the specified operation + * @param languageMap */ protected dispatchOperationsFromMap(valueMap: Map, pathCombiner: JsonPatchOperationPathCombiner, event: DynamicFormControlEvent, - previousValue: FormFieldPreviousValueObject): void { + previousValue: FormFieldPreviousValueObject, + languageMap: Map = null): void { const currentValueMap = valueMap; if (event.type === 'remove') { const path = this.getQualdropItemPathFromEvent(event); @@ -470,7 +475,8 @@ export class SectionFormOperationsService { const currentValue = currentValueMap.get(index); if (currentValue) { if (!isEqual(entry, currentValue)) { - this.operationsBuilder.add(pathCombiner.getPath(index), currentValue, true); + const metadataFromPath = pathCombiner.getPath(index).path.split('/').slice(-1)[0]; + this.operationsBuilder.add(pathCombiner.getPath(index), currentValue, true, false, languageMap?.get(metadataFromPath)); } currentValueMap.delete(index); } else if (!currentValue) { @@ -483,7 +489,8 @@ export class SectionFormOperationsService { // The last item of the group has been deleted so make a remove op this.operationsBuilder.remove(pathCombiner.getPath(index)); } else { - this.operationsBuilder.add(pathCombiner.getPath(index), entry, true); + const metadataFromPath = pathCombiner.getPath(index).path.split('/').slice(-1)[0]; + this.operationsBuilder.add(pathCombiner.getPath(index), entry, true, null, languageMap?.get(metadataFromPath)); } }); } diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts index cf32f179add..cb590389612 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -209,7 +209,7 @@ describe('SubmissionSectionFormComponent test suite', () => { SubmissionSectionFormComponent, ], schemas: [NO_ERRORS_SCHEMA], - }).compileComponents().then(); + }).overrideComponent(SubmissionSectionFormComponent, { remove: { imports: [FormComponent] } }).compileComponents().then(); })); describe('', () => { diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index f953c3020af..939d34c1b8b 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectorRef, Component, Inject, + OnDestroy, ViewChild, } from '@angular/core'; import { ObjectCacheService } from '@dspace/core/cache/object-cache.service'; @@ -57,6 +58,7 @@ import { } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; +import { DynamicQualdropModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormComponent } from '../../../shared/form/form.component'; import { FormService } from '../../../shared/form/form.service'; @@ -80,7 +82,7 @@ import { SectionFormOperationsService } from './section-form-operations.service' ThemedLoadingComponent, ], }) -export class SubmissionSectionFormComponent extends SectionModelComponent { +export class SubmissionSectionFormComponent extends SectionModelComponent implements OnDestroy { /** * The form id @@ -338,6 +340,8 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { ); const sectionMetadata = this.sectionService.computeSectionConfiguredMetadata(this.formConfig); this.sectionService.updateSectionData(this.submissionId, this.sectionData.id, sectionData, errorsToShow, serverValidationErrors, sectionMetadata); + // Add created model to formBulderService + this.formBuilderService.addFormModel(this.formId, this.formModel); } catch (e: unknown) { const msg: string = this.translate.instant('error.submission.sections.init-form-error') + (e as Error).toString(); const sectionError: SubmissionSectionError = { @@ -435,22 +439,57 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * the [[DynamicFormControlEvent]] emitted */ onChange(event: DynamicFormControlEvent): void { - this.formOperationsService.dispatchOperationsFromEvent( - this.pathCombiner, - event, - this.previousValue, - this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event))); + const languageMap = new Map(); + const isQualdrop = event.model.parent instanceof DynamicQualdropModel; + + if (isQualdrop) { + const qualdropMap = this.formOperationsService.getQualdropValueMap(event); + + if (qualdropMap) { + const groupMetadata = qualdropMap.keys(); + this.formService.getForm(this.formId).pipe(take(1)).subscribe((form) => { + for (const metadata of groupMetadata) { + if (hasValue(form.data[metadata]) && form.data[metadata].length > 1) { + form.data[metadata].forEach((entry: any) => { + languageMap.set(metadata, [...(languageMap.get(metadata) ?? []), entry.language]); + }); + } else { + languageMap.set(metadata, [form.data[metadata][0].language]); + } + } + }); + } + + this.formOperationsService.dispatchOperationsFromEvent( + this.pathCombiner, + event, + this.previousValue, + this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event)), + languageMap, + ); + } else { + this.formOperationsService.dispatchOperationsFromEvent( + this.pathCombiner, + event, + this.previousValue, + this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event)), + null, + ); + } + + const metadata = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event); const value = this.formOperationsService.getFieldValueFromChangeEvent(event); - if ((environment.submission.autosave.metadata.indexOf(metadata) !== -1 && isNotEmpty(value)) || this.hasRelatedCustomError(metadata)) { + const eventAutoSave = !event.$event?.hasOwnProperty('autoSave') || event.$event?.autoSave; + if (eventAutoSave && (environment.submission.autosave.metadata.indexOf(metadata) !== -1 && isNotEmpty(value)) || this.hasRelatedCustomError(metadata)) { this.submissionService.dispatchSave(this.submissionId); } } private hasRelatedCustomError(medatata): boolean { const index = findIndex(this.sectionData.errorsToShow, { path: this.pathCombiner.getPath(medatata).path }); - if (index !== -1) { + if (index !== -1) { const error = this.sectionData.errorsToShow[index]; const validator = error.message.replace('error.validation.', ''); return !environment.form.validatorMap.hasOwnProperty(validator); @@ -467,6 +506,10 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * the [[DynamicFormControlEvent]] emitted */ onFocus(event: DynamicFormControlEvent): void { + this.updatePreviousValue(event); + } + + private updatePreviousValue(event: DynamicFormControlEvent): void { const value = this.formOperationsService.getFieldValueFromChangeEvent(event); const path = this.formBuilderService.getPath(event.model); if (this.formBuilderService.hasMappedGroupValue(event.model)) { @@ -478,6 +521,11 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { } } + private clearPreviousValue(): void { + this.previousValue.path = null; + this.previousValue.value = null; + } + /** * Method called when a form remove event is fired. * Dispatch form operations based on changes. @@ -486,6 +534,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { * the [[DynamicFormControlEvent]] emitted */ onRemove(event: DynamicFormControlEvent): void { + this.updatePreviousValue(event); const fieldId = this.formBuilderService.getId(event.model); const fieldIndex = this.formOperationsService.getArrayIndexFromEvent(event); @@ -503,7 +552,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { event, this.previousValue, this.hasStoredValue(fieldId, fieldIndex)); - + this.clearPreviousValue(); } /** @@ -539,7 +588,7 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { /** * Handle the customEvent (ex. drag-drop move event). * The customEvent is stored inside event.$event - * @param $event + * @param event */ onCustomEvent(event: DynamicFormControlEvent) { this.formOperationsService.dispatchOperationsFromEvent( @@ -548,4 +597,11 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { this.previousValue, null); } + + ngOnDestroy(): void { + super.ngOnDestroy(); + // Remove this model from formBulderService + this.formBuilderService.removeFormModel(this.sectionData.id); + } + } diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index 428a13766a0..bf77fc455fa 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -109,6 +109,8 @@ export class SubmissionService { private workspaceLinkPath = 'workspaceitems'; private workflowLinkPath = 'workflowitems'; + private editItemsLinkPath = 'edititems'; + /** * Initialize service variables * @param {NotificationsService} notificationsService @@ -226,8 +228,10 @@ export class SubmissionService { * The [SubmissionDefinitionsModel] that define submission configuration * @param sections * The [WorkspaceitemSectionsObject] that define submission sections init data + * @param item * @param errors * The [SubmissionSectionError] that define submission sections init errors + * @param metadataSecurityConfiguration */ dispatchInit( collectionId: string, @@ -419,6 +423,9 @@ export class SubmissionService { case this.workflowLinkPath: scope = SubmissionScopeType.WorkflowItem; break; + case this.editItemsLinkPath: + scope = SubmissionScopeType.EditItem; + break; } return scope; } @@ -588,6 +595,8 @@ export class SubmissionService { * The [SubmissionDefinitionsModel] that define submission configuration * @param sections * The [WorkspaceitemSectionsObject] that define submission sections init data + * @param item + * @param metadataSecurityConfiguration */ resetSubmissionObject( collectionId: string, @@ -606,8 +615,8 @@ export class SubmissionService { * @return Observable> * observable of RemoteData */ - retrieveSubmission(submissionId): Observable> { - return this.restService.getDataById(this.getSubmissionObjectLinkName(), submissionId).pipe( + retrieveSubmission(submissionId, projections: string[] = []): Observable> { + return this.restService.getDataById(this.getSubmissionObjectLinkName(), submissionId, false, projections).pipe( find((submissionObjects: SubmissionObject[]) => isNotUndefined(submissionObjects)), map((submissionObjects: SubmissionObject[]) => createSuccessfulRemoteDataObject( submissionObjects[0])), diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 0fc94d16210..ec69570545a 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2143,6 +2143,40 @@ "form.other-information.orcid": "ORCID", + "form.other-information.person_identifier_orcid": "ORCID", + + "form.other-information.oairecerif_author_affiliation": "Affiliation", + + "form.other-information.oairecerif_editor_affiliation": "Affiliation", + + "form.other-information.person_identifier_orcid": "ORCID iD", + + "form.other-information.institution-affiliation-name": "Affiliation(s)", + + "form.other-information.dc_relation_grantno": "Grant Number", + + "form.other-information.not-available": "Not available", + + "form.other-information.ror_orgunit_id": "ROR ID", + + "form.other-information.ror_orgunit_type": "ROR type", + + "form.other-information.ror_orgunit_acronym": "ROR acronym", + + "form.other-information.ror_orgunit_countryName": "ROR country", + + "form.entry.source.local": "", + + "form.entry.source.orcid": "", + + "form.entry.source.ror": "", + + "form.entry.source.openaire": "", + + "form.entry.source.zdb": "", + + "form.entry.source.sherpa": "- Sherpa Romeo", + "form.remove": "Remove", "form.save": "Save", @@ -2627,6 +2661,8 @@ "item.edit.metadata.headers.language": "Lang", + "item.edit.metadata.headers.authority": "Authority", + "item.edit.metadata.headers.value": "Value", "item.edit.metadata.metadatafield": "Edit field", @@ -3311,6 +3347,8 @@ "itemtemplate.edit.metadata.headers.value": "Value", + "itemtemplate.edit.metadata.headers.authority": "Authority", + "itemtemplate.edit.metadata.metadatafield": "Edit field", "itemtemplate.edit.metadata.metadatafield.error": "An error occurred validating the metadata field", @@ -6390,6 +6428,8 @@ "home.recent-submissions.head": "Recent Submissions", + "authority-confidence.search-label": "Search", + "listable-notification-object.default-message": "This object couldn't be retrieved", "system-wide-alert-banner.retrieval.error": "Something went wrong retrieving the system-wide alert banner", @@ -7291,4 +7331,22 @@ "item.preview.organization.address.addressLocality": "City", "item.preview.organization.alternateName": "Alternative name", + + "metadata-link-view.popover.label.Person.dc.title": "Fullname", + + "metadata-link-view.popover.label.Person.person.affiliation.name": "Main affiliation", + + "metadata-link-view.popover.label.Person.person.email": "Email", + + "metadata-link-view.popover.label.Person.person.identifier.orcid": "ORCID", + + "metadata-link-view.popover.label.Person.dc.description.abstract": "Abstract", + + "metadata-link-view.popover.label.Person.person.jobTitle": "Job title", + + "metadata-link-view.popover.label.other.dc.title": "Title", + + "metadata-link-view.popover.label.other.dc.description.abstract": "Description", + + "metadata-link-view.popover.label.more-info": "More info", } diff --git a/src/assets/images/local.logo.icon.svg b/src/assets/images/local.logo.icon.svg new file mode 100644 index 00000000000..fe25b833c9d --- /dev/null +++ b/src/assets/images/local.logo.icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/openaire.logo.icon.svg b/src/assets/images/openaire.logo.icon.svg new file mode 100644 index 00000000000..ea4d2014d5b --- /dev/null +++ b/src/assets/images/openaire.logo.icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/ror.logo.icon.svg b/src/assets/images/ror.logo.icon.svg new file mode 100644 index 00000000000..6bea5c60952 --- /dev/null +++ b/src/assets/images/ror.logo.icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/images/sherpa.logo.icon.svg b/src/assets/images/sherpa.logo.icon.svg new file mode 100644 index 00000000000..e6a4921f3e6 --- /dev/null +++ b/src/assets/images/sherpa.logo.icon.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/src/assets/images/zdb.logo.icon.svg b/src/assets/images/zdb.logo.icon.svg new file mode 100644 index 00000000000..a495cc2718f --- /dev/null +++ b/src/assets/images/zdb.logo.icon.svg @@ -0,0 +1 @@ +180509 ZDB-Logo diff --git a/src/config/additional-metadata.config.ts b/src/config/additional-metadata.config.ts new file mode 100644 index 00000000000..968cbd247c2 --- /dev/null +++ b/src/config/additional-metadata.config.ts @@ -0,0 +1,23 @@ +import { Config } from './config.interface'; + +export type AdditionalMetadataConfigRenderingTypes = + 'text' + | 'crisref' + | 'link' + | 'link.email' + | 'identifier' + | 'valuepair' + | 'date' + | 'authors' + | 'currentRole' + | 'lastRole'; + +export interface AdditionalMetadataConfig extends Config { + name: string, + rendering: AdditionalMetadataConfigRenderingTypes, + label?: string, + prefix?: string, + suffix?: string, + limitTo?: number, + startFromLast?: boolean +} diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 681e494c6bd..297e2a3d61d 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -19,16 +19,21 @@ import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { FormConfig } from './form-config.interfaces'; import { GeospatialMapConfig } from './geospatial-map-config'; import { HomeConfig } from './homepage-config.interface'; +import { IdentifierSubtypesConfig } from './identifier-subtypes-config.interface'; import { InfoConfig } from './info-config.interface'; import { ItemConfig } from './item-config.interface'; import { LangConfig } from './lang-config.interface'; +import { LayoutConfig } from './layout-config.interfaces'; import { LiveRegionConfig } from './live-region.config'; import { MarkdownConfig } from './markdown-config.interface'; import { MatomoConfig } from './matomo-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; +import { MetadataLinkViewPopoverDataConfig } from './metadata-link-view-popoverdata-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { QualityAssuranceConfig } from './quality-assurance.config'; +import { FollowAuthorityMetadata } from './search-follow-metadata.interface'; import { SearchConfig } from './search-page-config.interface'; +import { SearchResultConfig } from './search-result-config.interface'; import { ServerConfig } from './server-config.interface'; import { SubmissionConfig } from './submission-config.interface'; import { SuggestionConfig } from './suggestion-config.interfaces'; @@ -69,6 +74,13 @@ interface AppConfig extends Config { matomo?: MatomoConfig; geospatialMapViewer: GeospatialMapConfig; accessibility: AccessibilitySettingsConfig; + layout: LayoutConfig; + metadataLinkViewPopoverData: MetadataLinkViewPopoverDataConfig; + identifierSubtypes: IdentifierSubtypesConfig[]; + searchResult: SearchResultConfig; + followAuthorityMetadata: FollowAuthorityMetadata[]; + followAuthorityMaxItemLimit: number; + followAuthorityMetadataValuesLimit: number; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 4840c93e1ba..0ab5dd70eeb 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -1,3 +1,6 @@ +import { LayoutConfig } from '@dspace/config/layout-config.interfaces'; +import { SearchResultConfig } from '@dspace/config/search-result-config.interface'; + import { AccessibilitySettingsConfig } from './accessibility-settings.config'; import { ActuatorsConfig } from './actuators.config'; import { AdminNotifyMetricsRow } from './admin-notify-metrics.config'; @@ -14,6 +17,10 @@ import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { FormConfig } from './form-config.interfaces'; import { GeospatialMapConfig } from './geospatial-map-config'; import { HomeConfig } from './homepage-config.interface'; +import { + IdentifierSubtypesConfig, + IdentifierSubtypesIconPositionEnum, +} from './identifier-subtypes-config.interface'; import { InfoConfig } from './info-config.interface'; import { ItemConfig } from './item-config.interface'; import { LangConfig } from './lang-config.interface'; @@ -21,12 +28,14 @@ import { LiveRegionConfig } from './live-region.config'; import { MarkdownConfig } from './markdown-config.interface'; import { MatomoConfig } from './matomo-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; +import { MetadataLinkViewPopoverDataConfig } from './metadata-link-view-popoverdata-config.interface'; import { INotificationBoardOptions, NotificationAnimationsType, } from './notifications-config.interfaces'; import { QualityAssuranceConfig } from './quality-assurance.config'; import { RestRequestMethod } from './rest-request-method'; +import { FollowAuthorityMetadata } from './search-follow-metadata.interface'; import { SearchConfig } from './search-page-config.interface'; import { ServerConfig } from './server-config.interface'; import { SubmissionConfig } from './submission-config.interface'; @@ -252,6 +261,32 @@ export class DefaultAppConfig implements AppConfig { }, ], + sourceIcons: [ + { + source: 'orcid', + path: 'assets/images/orcid.logo.icon.svg', + }, + { + source: 'openaire', + path: 'assets/images/openaire.logo.icon.svg', + }, + { + source: 'ror', + path: 'assets/images/ror.logo.icon.svg', + }, + { + source: 'sherpa', + path: 'assets/images/sherpa.logo.icon.svg', + }, + { + source: 'zdb', + path: 'assets/images/zdb.logo.icon.svg', + }, + { + source: 'local', + path: 'assets/images/local.logo.icon.svg', + }, + ], }, }, }; @@ -646,4 +681,97 @@ export class DefaultAppConfig implements AppConfig { accessibility: AccessibilitySettingsConfig = { cookieExpirationDuration: 7, }; + + layout: LayoutConfig = { + authorityRef: [ + { + entityType: 'DEFAULT', + entityStyle: { + default: { + icon: 'fa fa-info', + style: 'text-info', + }, + }, + }, + { + entityType: 'PERSON', + entityStyle: { + default: { + icon: 'fa fa-user', + style: 'text-info', + }, + }, + }, + { + entityType: 'ORGUNIT', + entityStyle: { + default: { + icon: 'fa fa-university', + style: 'text-info', + }, + }, + }, + { + entityType: 'PROJECT', + entityStyle: { + default: { + icon: 'fas fa-project-diagram', + style: 'text-info', + }, + }, + }, + ], + }; + + searchResult: SearchResultConfig = { + additionalMetadataFields: [], + authorMetadata: ['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], + }; + + // Configuration for the metadata link view popover + metadataLinkViewPopoverData: MetadataLinkViewPopoverDataConfig = { + fallbackMetdataList: ['dc.description.abstract'], + + entityDataConfig: [ + { + entityType: 'Person', + metadataList: ['person.affiliation.name', 'person.email', 'person.jobTitle', 'dc.description.abstract'], + titleMetadataList: ['person.givenName', 'person.familyName' ], + }, + ], + }; + + identifierSubtypes: IdentifierSubtypesConfig[] = [ + { + name: 'ror', + icon: 'assets/images/ror.logo.icon.svg', + iconPosition: IdentifierSubtypesIconPositionEnum.LEFT, + link: 'https://ror.org', + }, + ]; + + // 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; + + // 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: FollowAuthorityMetadata[] = [ + { + type: 'Publication', + metadata: ['dc.contributor.author'], + }, + { + type: 'Product', + metadata: ['dc.contributor.author'], + }, + { + type: 'Patent', + metadata: ['dc.contributor.author'], + }, + ]; + } diff --git a/src/config/identifier-subtypes-config.interface.ts b/src/config/identifier-subtypes-config.interface.ts new file mode 100644 index 00000000000..370fd2a1bf2 --- /dev/null +++ b/src/config/identifier-subtypes-config.interface.ts @@ -0,0 +1,15 @@ +/** + * Represents the configuration for identifier subtypes. + */ +export interface IdentifierSubtypesConfig { + name: string; // The name of the identifier subtype + icon: string; // The icon to display for the identifier subtype + iconPosition: IdentifierSubtypesIconPositionEnum; // The position of the icon relative to the identifier + link: string; // The link to navigate to when the icon is clicked +} + +export enum IdentifierSubtypesIconPositionEnum { + NONE = 'NONE', + LEFT = 'LEFT', + RIGHT = 'RIGHT', +} diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts new file mode 100644 index 00000000000..c2a0a5f7d2e --- /dev/null +++ b/src/config/layout-config.interfaces.ts @@ -0,0 +1,19 @@ +import { Config } from './config.interface'; + +export interface AuthorityRefEntityStyleConfig extends Config { + icon: string; + style: string; +} + +export interface AuthorityRefConfig extends Config { + entityType: string; + entityStyle: { + default: AuthorityRefEntityStyleConfig; + [entity: string]: AuthorityRefEntityStyleConfig; + }; +} + + +export interface LayoutConfig extends Config { + authorityRef: AuthorityRefConfig[]; +} diff --git a/src/config/metadata-link-view-popoverdata-config.interface.ts b/src/config/metadata-link-view-popoverdata-config.interface.ts new file mode 100644 index 00000000000..0193722e3ee --- /dev/null +++ b/src/config/metadata-link-view-popoverdata-config.interface.ts @@ -0,0 +1,27 @@ +export interface MetadataLinkViewPopoverDataConfig { + /** + * The list of entity types to display the metadata for + */ + entityDataConfig: EntityDataConfig[]; + + /** + * The list of metadata keys to fallback to + */ + fallbackMetdataList: string[]; +} + + +export interface EntityDataConfig { + /** + * The metadata entity type + */ + entityType: string; + /** + * The list of metadata keys to display + */ + metadataList: string[]; + /** + * The list of title metadata keys to display as title (optional as default is on dc.title) + **/ + titleMetadataList?: string[]; +} diff --git a/src/config/search-follow-metadata.interface.ts b/src/config/search-follow-metadata.interface.ts new file mode 100644 index 00000000000..b6c60a5f23a --- /dev/null +++ b/src/config/search-follow-metadata.interface.ts @@ -0,0 +1,18 @@ +import { Config } from './config.interface'; + +/** + * Config that determines how to follow metadata of search results. + */ +export interface FollowAuthorityMetadata extends Config { + + /** + * The type of the browse by dspace object result. + */ + type: string; + + /** + * The metadata to follow of the browse by dspace object result. + */ + metadata: string[]; + +} diff --git a/src/config/search-result-config.interface.ts b/src/config/search-result-config.interface.ts new file mode 100644 index 00000000000..e90a080a21b --- /dev/null +++ b/src/config/search-result-config.interface.ts @@ -0,0 +1,12 @@ +import { AdditionalMetadataConfig } from './additional-metadata.config'; +import { Config } from './config.interface'; + +export interface SearchResultConfig extends Config { + additionalMetadataFields: SearchResultAdditionalMetadataEntityTypeConfig[], + authorMetadata: string[]; +} + +export interface SearchResultAdditionalMetadataEntityTypeConfig extends Config { + entityType: string, + metadataConfiguration: Array[] +} diff --git a/src/config/submission-config.interface.ts b/src/config/submission-config.interface.ts index afc81a39e25..9d3ebe57a36 100644 --- a/src/config/submission-config.interface.ts +++ b/src/config/submission-config.interface.ts @@ -13,10 +13,16 @@ interface TypeBindConfig extends Config { field: string; } +export interface AuthorithyIcon { + source: string, + path: string +} + interface IconsConfig extends Config { metadata: MetadataIconConfig[]; authority: { confidence: ConfidenceIconConfig[]; + sourceIcons?: AuthorithyIcon[] }; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index a93c1fe9389..dd855edc656 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -477,4 +477,98 @@ export const environment: BuildConfig = { accessibility: { cookieExpirationDuration: 7, }, + + layout: { + authorityRef: [ + { + entityType: 'DEFAULT', + entityStyle: { + default: { + icon: 'fa fa-user', + style: 'text-success', + }, + }, + }, + { + entityType: 'PERSON', + entityStyle: { + person: { + icon: 'fa fa-user', + style: 'text-success', + }, + personStaff: { + icon: 'fa fa-user', + style: 'text-primary', + }, + default: { + icon: 'fa fa-user', + style: 'text-success', + }, + }, + }, + { + entityType: 'ORGUNIT', + entityStyle: { + default: { + icon: 'fa fa-university', + style: 'text-success', + }, + }, + }, + ], + }, + + searchResult: { + additionalMetadataFields: [], + authorMetadata: ['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], + }, + + metadataLinkViewPopoverData: { + fallbackMetdataList: ['dc.description.abstract'], + + entityDataConfig: [ + { + entityType: 'Person', + metadataList: ['person.affiliation.name', 'person.email', 'person.identifier.orcid', 'dc.description.abstract'], + titleMetadataList: ['person.givenName', 'person.familyName' ], + }, + { + entityType: 'OrgUnit', + metadataList: ['organization.parentOrganization', 'organization.identifier.ror', 'crisou.director', 'dc.description.abstract'], + }, + { + entityType: 'Project', + metadataList: ['oairecerif.project.status', 'dc.description.abstract'], + }, + { + entityType: 'Funding', + metadataList: ['oairecerif.funder', 'oairecerif.fundingProgram', 'dc.description.abstract'], + }, + { + entityType: 'Publication', + metadataList: ['dc.identifier.doi', 'dc.identifier.uri', 'dc.description.abstract'], + }, + ], + }, + + identifierSubtypes: [], + + followAuthorityMaxItemLimit: 100, + + followAuthorityMetadataValuesLimit: 5, + + followAuthorityMetadata: [ + { + type: 'Publication', + metadata: ['dc.contributor.author'], + }, + { + type: 'Product', + metadata: ['dc.contributor.author'], + }, + { + type: 'Patent', + metadata: ['dc.contributor.author'], + }, + ], }; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 8b0e1ad32f7..c9410b60329 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -125,6 +125,7 @@ --ds-dso-edit-field-width: 210px; --ds-dso-edit-lang-width: 90px; + --ds-dso-edit-authority-width: 150px; --ds-dso-edit-actions-width: 173px; --ds-dso-edit-virtual-tooltip-min-width: 300px; @@ -164,4 +165,5 @@ --ds-filters-skeleton-height: 40px; --ds-filters-skeleton-spacing: 12px; + --ds-identifier-subtype-icon-height: 24px; } diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index eb39b8c7866..b1012c9a1f7 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -38,3 +38,21 @@ background-color: var(--ds-dark-scrollbar-bg); } } + +/* Define a mixin for vertical ellipsis + To be used as a class e.g. ellipsis-y-1,... ellipsis-y-4,... ellipsis-y-10 + */ +@mixin ellipsis-y($lines) { + display: -webkit-box; + -webkit-line-clamp: $lines; // number of lines + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +// Generate classes for 1 to 10 lines +@for $i from 1 through 10 { + .ellipsis-y-#{$i} { + @include ellipsis-y($i); + } +} diff --git a/src/themes/custom/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/themes/custom/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index 5ae2eac5d88..2e34e555eaf 100644 --- a/src/themes/custom/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/themes/custom/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -6,6 +6,7 @@ import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { fadeInOut } from '../../../../../../../app/shared/animations/fade'; +import { MetadataLinkViewComponent } from '../../../../../../../app/shared/metadata-link-view/metadata-link-view.component'; import { ThemedBadgesComponent } from '../../../../../../../app/shared/object-collection/shared/badges/themed-badges.component'; import { ItemCollectionComponent } from '../../../../../../../app/shared/object-collection/shared/mydspace-item-collection/item-collection.component'; import { ItemSubmitterComponent } from '../../../../../../../app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component'; @@ -25,6 +26,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the AsyncPipe, ItemCollectionComponent, ItemSubmitterComponent, + MetadataLinkViewComponent, NgClass, ThemedBadgesComponent, ThemedThumbnailComponent, diff --git a/src/themes/custom/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts b/src/themes/custom/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts index a370c0e3467..fd54859022d 100644 --- a/src/themes/custom/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts +++ b/src/themes/custom/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts @@ -8,6 +8,7 @@ import { Context } from '@dspace/core/shared/context.model'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; +import { MetadataLinkViewComponent } from '../../../../../../../../../app/shared/metadata-link-view/metadata-link-view.component'; import { ThemedBadgesComponent } from '../../../../../../../../../app/shared/object-collection/shared/badges/themed-badges.component'; import { listableObjectComponent } from '../../../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ItemSearchResultListElementComponent as BaseComponent } from '../../../../../../../../../app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component'; @@ -25,6 +26,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../../../app/thumbna templateUrl: '../../../../../../../../../app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html', imports: [ AsyncPipe, + MetadataLinkViewComponent, NgClass, RouterLink, ThemedBadgesComponent,