diff --git a/config/config.example.yml b/config/config.example.yml index ca30e900496..9065cb7faf3 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -235,6 +235,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 condigured 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 @@ -629,3 +635,98 @@ 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 + +# 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: + - 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/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 0488a9631b9..a970a928566 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,4 +1,5 @@ import { HttpClient } from '@angular/common/http'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { Store } from '@ngrx/store'; import { cold, @@ -13,6 +14,7 @@ import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core-state.model'; import { NotificationsService } from '../notification-system/notifications.service'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { Item } from '../shared/item.model'; import { HALEndpointServiceStub } from '../testing/hal-endpoint-service.stub'; import { getMockRemoteDataBuildService } from '../testing/remote-data-build.service.mock'; import { getMockRequestService } from '../testing/request.service.mock'; @@ -209,4 +211,93 @@ describe('ItemDataService', () => { }); }); + describe('findByCustomUrl', () => { + let itemDataService: ItemDataService; + let searchData: any; + let findByHrefSpy: jasmine.Spy; + let getSearchByHrefSpy: jasmine.Spy; + const id = 'custom-id'; + const fakeHrefObs = of('https://rest.api/core/items/search/findByCustomURL?q=custom-id'); + const linksToFollow = []; + const projections = ['full', 'detailed']; + + beforeEach(() => { + searchData = jasmine.createSpyObj('searchData', ['getSearchByHref']); + getSearchByHrefSpy = searchData.getSearchByHref.and.returnValue(fakeHrefObs); + itemDataService = new ItemDataService( + requestService, + rdbService, + objectCache, + halEndpointService, + notificationsService, + comparator, + browseService, + bundleService, + ); + + (itemDataService as any).searchData = searchData; + findByHrefSpy = spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item())); + }); + + it('should call searchData.getSearchByHref with correct parameters', () => { + itemDataService.findByCustomUrl(id, true, true, linksToFollow, projections).subscribe(); + + expect(getSearchByHrefSpy).toHaveBeenCalledWith( + 'findByCustomURL', + jasmine.objectContaining({ + searchParams: jasmine.arrayContaining([ + jasmine.objectContaining({ fieldName: 'q', fieldValue: id }), + jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'full' }), + jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'detailed' }), + ]), + }), + ...linksToFollow, + ); + }); + + it('should call findByHref with the href observable returned from getSearchByHref', () => { + itemDataService.findByCustomUrl(id, true, false, linksToFollow, projections).subscribe(); + + expect(findByHrefSpy).toHaveBeenCalledWith(fakeHrefObs, true, false, ...linksToFollow); + }); + }); + + describe('findById', () => { + let itemDataService: ItemDataService; + + beforeEach(() => { + itemDataService = new ItemDataService( + requestService, + rdbService, + objectCache, + halEndpointService, + notificationsService, + comparator, + browseService, + bundleService, + ); + spyOn(itemDataService, 'findByCustomUrl').and.returnValue(createSuccessfulRemoteDataObject$(new Item())); + spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item())); + spyOn(itemDataService as any, 'getIDHrefObs').and.returnValue(of('uuid-href')); + }); + + it('should call findByHref when given a valid UUID', () => { + const validUuid = '4af28e99-6a9c-4036-a199-e1b587046d39'; + itemDataService.findById(validUuid).subscribe(); + + expect((itemDataService as any).getIDHrefObs).toHaveBeenCalledWith(encodeURIComponent(validUuid)); + expect(itemDataService.findByHref).toHaveBeenCalled(); + expect(itemDataService.findByCustomUrl).not.toHaveBeenCalled(); + }); + + it('should call findByCustomUrl when given a non-UUID id', () => { + const nonUuid = 'custom-url'; + itemDataService.findById(nonUuid).subscribe(); + + expect(itemDataService.findByCustomUrl).toHaveBeenCalledWith(nonUuid, true, true, []); + expect(itemDataService.findByHref).not.toHaveBeenCalled(); + }); + }); + + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index cb7e62c8fe6..c20dc0a1834 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -24,6 +24,7 @@ import { switchMap, take, } from 'rxjs/operators'; +import { validate as uuidValidate } from 'uuid'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -34,6 +35,7 @@ import { NotificationsService } from '../notification-system/notifications.servi import { Bundle } from '../shared/bundle.model'; import { Collection } from '../shared/collection.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { FollowLinkConfig } from '../shared/follow-link-config.model'; import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; @@ -58,6 +60,7 @@ import { PatchData, PatchDataImpl, } from './base/patch-data'; +import { SearchDataImpl } from './base/search-data'; import { BundleDataService } from './bundle-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { FindListOptions } from './find-list-options.model'; @@ -83,6 +86,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService private createData: CreateData; private patchData: PatchData; private deleteData: DeleteData; + private searchData: SearchDataImpl; protected constructor( protected linkPath, @@ -101,6 +105,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -425,6 +430,57 @@ export abstract class BaseItemDataService extends IdentifiableDataService return this.createData.create(object, ...params); } + /** + * Returns an observable of {@link RemoteData} of an object, based on its CustomURL or ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id CustomUrl or UUID of object we want to retrieve + * @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 + * @param projections List of {@link projections} used to pass as parameters + */ + public findByCustomUrl(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, linksToFollow: FollowLinkConfig[], projections: string[] = []): Observable> { + const searchHref = 'findByCustomURL'; + + const options = Object.assign({}, { + searchParams: [ + new RequestParam('q', id), + ], + }); + + projections.forEach((projection) => { + options.searchParams.push(new RequestParam('projection', projection)); + }); + + const hrefObs = this.searchData.getSearchByHref(searchHref, options, ...linksToFollow); + + return this.findByHref(hrefObs, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @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 + */ + public findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + + if (uuidValidate(id)) { + const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } else { + return this.findByCustomUrl(id, useCachedVersionIfAvailable, reRequestOnStale, linksToFollow); + } + } + } /** diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 5307709ad4f..48600b35914 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -4,6 +4,7 @@ import { makeEnvironmentProviders, } from '@angular/core'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { SubmissionCustomUrl } from '@dspace/core/submission/models/submission-custom-url.model'; import { AuthStatus } from './auth/models/auth-status.model'; import { ShortLivedToken } from './auth/models/short-lived-token.model'; @@ -228,4 +229,5 @@ export const models = StatisticsEndpoint, CorrectionType, SupervisionOrder, + SubmissionCustomUrl, ]; diff --git a/src/app/core/router/utils/dso-route.utils.ts b/src/app/core/router/utils/dso-route.utils.ts index d377610d08d..fc0f8b4ffda 100644 --- a/src/app/core/router/utils/dso-route.utils.ts +++ b/src/app/core/router/utils/dso-route.utils.ts @@ -31,9 +31,16 @@ export function getCommunityPageRoute(communityId: string) { */ export function getItemPageRoute(item: Item) { const type = item.firstMetadataValue('dspace.entity.type'); - return getEntityPageRoute(type, item.uuid); + let url = item.uuid; + + if (isNotEmpty(item.metadata) && item.hasMetadata('dspace.customurl')) { + url = item.firstMetadataValue('dspace.customurl'); + } + + return getEntityPageRoute(type, url); } + export function getEntityPageRoute(entityType: string, itemId: string) { if (isNotEmpty(entityType)) { return new URLCombiner(`/${ENTITY_MODULE_PATH}`, encodeURIComponent(entityType.toLowerCase()), itemId).toString(); 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/item.model.ts b/src/app/core/shared/item.model.ts index 06fc0b01e84..aefe936b15e 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 boolean representing if this Item is currently withdrawn or not + */ + @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..6e9b2a1f516 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -1,4 +1,5 @@ /* eslint-disable max-classes-per-file */ +import { hasValue } from '@dspace/shared/utils/empty.util'; import { autoserialize, Deserialize, @@ -6,6 +7,7 @@ import { } from 'cerialize'; import { v4 as uuidv4 } from 'uuid'; + export const VIRTUAL_METADATA_PREFIX = 'virtual::'; /** A single metadata value and its properties. */ @@ -56,6 +58,24 @@ export class MetadataValue implements MetadataValueInterface { @autoserialize confidence: number; + /** + * Returns true if this Metadatum's authority key starts with 'virtual::' + */ + get isVirtual(): boolean { + return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX); + } + + /** + * If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'. + * Returns undefined otherwise. + */ + get virtualValue(): string { + if (this.isVirtual) { + return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); + } else { + return undefined; + } + } } /** Constraints for matching metadata values. */ @@ -74,6 +94,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/models/submission-custom-url.model.ts b/src/app/core/submission/models/submission-custom-url.model.ts new file mode 100644 index 00000000000..828080bac3f --- /dev/null +++ b/src/app/core/submission/models/submission-custom-url.model.ts @@ -0,0 +1,27 @@ +import { + autoserialize, + inheritSerialization, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALResource } from '../../shared/hal-resource.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { SUBMISSION_CUSTOM_URL } from './submission-custom-url.resource-type'; + +@typedObject +@inheritSerialization(HALResource) +export class SubmissionCustomUrl extends HALResource { + + static type = SUBMISSION_CUSTOM_URL; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + url: string; +} diff --git a/src/app/core/submission/models/submission-custom-url.resource-type.ts b/src/app/core/submission/models/submission-custom-url.resource-type.ts new file mode 100644 index 00000000000..a3eae70f522 --- /dev/null +++ b/src/app/core/submission/models/submission-custom-url.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for License + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUBMISSION_CUSTOM_URL = new ResourceType('submissioncustomcurl'); diff --git a/src/app/core/submission/models/workspaceitem-section-custom-url.model.ts b/src/app/core/submission/models/workspaceitem-section-custom-url.model.ts new file mode 100644 index 00000000000..81f28433711 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-custom-url.model.ts @@ -0,0 +1,7 @@ +/** + * An interface to represent the submission's custom url section data. + */ +export interface WorkspaceitemSectionCustomUrlObject { + 'redirected-urls': string[]; + 'url': string; +} diff --git a/src/app/core/submission/sections-type.ts b/src/app/core/submission/sections-type.ts index 60b4cedfdc9..253670c13b0 100644 --- a/src/app/core/submission/sections-type.ts +++ b/src/app/core/submission/sections-type.ts @@ -4,6 +4,7 @@ export enum SectionsType { Upload = 'upload', License = 'license', CcLicense = 'cclicense', + CustomUrl = 'custom-url', AccessesCondition = 'accessCondition', SherpaPolicies = 'sherpaPolicy', Identifiers = 'identifiers', diff --git a/src/app/core/submission/submission-scope-type.ts b/src/app/core/submission/submission-scope-type.ts index f319e5c473e..683472370d4 100644 --- a/src/app/core/submission/submission-scope-type.ts +++ b/src/app/core/submission/submission-scope-type.ts @@ -1,4 +1,5 @@ export enum SubmissionScopeType { WorkspaceItem = 'WORKSPACE', WorkflowItem = 'WORKFLOW', + EditItem = 'EDIT' } diff --git a/src/app/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/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 031e407e459..88818fd2761 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 051aec9b439..ff84e00d8b6 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 { NgbTooltipModule } 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/item-page.resolver.spec.ts b/src/app/item-page/item-page.resolver.spec.ts index e410fa271f1..7da5b8ff8bb 100644 --- a/src/app/item-page/item-page.resolver.spec.ts +++ b/src/app/item-page/item-page.resolver.spec.ts @@ -101,4 +101,72 @@ describe('itemPageResolver', () => { }); }); + + describe('when item has dspace.customurl metadata', () => { + + + const customUrl = 'my-custom-item'; + let resolver: any; + let itemService: any; + let store: any; + let router: Router; + let authService: AuthServiceStub; + + const uuid = '1234-65487-12354-1235'; + let item: DSpaceObject; + + beforeEach(() => { + router = TestBed.inject(Router); + item = Object.assign(new DSpaceObject(), { + uuid: uuid, + firstMetadataValue(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): string { + return _keyOrKeys === 'dspace.entity.type' ? 'person' : customUrl; + }, + hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean { + return true; + }, + metadata: { + 'dspace.customurl': customUrl, + }, + }); + itemService = { + findById: (_id: string) => createSuccessfulRemoteDataObject$(item), + }; + store = jasmine.createSpyObj('store', { + dispatch: {}, + }); + authService = new AuthServiceStub(); + resolver = itemPageResolver; + }); + + it('should navigate to the new custom URL if dspace.customurl is defined and different from route param', (done) => { + spyOn(router, 'navigateByUrl').and.callThrough(); + + const route = { params: { id: uuid } } as any; + const state = { url: `/entities/person/${uuid}` } as any; + + resolver(route, state, router, itemService, store, authService) + .pipe(first()) + .subscribe((rd: any) => { + const expectedUrl = `/entities/person/${customUrl}`; + expect(router.navigateByUrl).toHaveBeenCalledWith(expectedUrl); + done(); + }); + }); + + it('should not navigate if dspace.customurl matches the current route id', (done) => { + spyOn(router, 'navigateByUrl').and.callThrough(); + + const route = { params: { id: customUrl } } as any; + const state = { url: `/entities/person/${customUrl}` } as any; + + resolver(route, state, router, itemService, store, authService) + .pipe(first()) + .subscribe((rd: any) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + done(); + }); + }); + }); + }); diff --git a/src/app/item-page/item-page.resolver.ts b/src/app/item-page/item-page.resolver.ts index 9851fc1a5a2..d2c59f6e1b3 100644 --- a/src/app/item-page/item-page.resolver.ts +++ b/src/app/item-page/item-page.resolver.ts @@ -59,18 +59,27 @@ export const itemPageResolver: ResolveFn> = ( return itemRD$.pipe( map((rd: RemoteData) => { if (rd.hasSucceeded && hasValue(rd.payload)) { - const thisRoute = state.url; + const isItemEditPage = state.url.includes('/edit'); + let itemRoute = isItemEditPage ? state.url : router.parseUrl(getItemPageRoute(rd.payload)).toString(); + if (hasValue(rd.payload.metadata) && rd.payload.hasMetadata('dspace.customurl')) { + if (route.params.id !== rd.payload.firstMetadataValue('dspace.customurl')) { + const newUrl = itemRoute.replace(route.params.id,rd.payload.firstMetadataValue('dspace.customurl')); + router.navigateByUrl(newUrl); + } + } else { + const thisRoute = state.url; - // Angular uses a custom function for encodeURIComponent, (e.g. it doesn't encode commas - // or semicolons) and thisRoute has been encoded with that function. If we want to compare - // it with itemRoute, we have to run itemRoute through Angular's version as well to ensure - // the same characters are encoded the same way. - const itemRoute = router.parseUrl(getItemPageRoute(rd.payload)).toString(); + // Angular uses a custom function for encodeURIComponent, (e.g. it doesn't encode commas + // or semicolons) and thisRoute has been encoded with that function. If we want to compare + // it with itemRoute, we have to run itemRoute through Angular's version as well to ensure + // the same characters are encoded the same way. + itemRoute = router.parseUrl(getItemPageRoute(rd.payload)).toString(); - if (!thisRoute.startsWith(itemRoute)) { - const itemId = rd.payload.uuid; - const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length); - void router.navigateByUrl(itemRoute + subRoute); + if (!thisRoute.startsWith(itemRoute)) { + const itemId = rd.payload.uuid; + const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length); + void router.navigateByUrl(itemRoute + subRoute); + } } } return rd; 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/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index da6de76594c..4a60c571d51 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,18 +7,33 @@
    -
  • {{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}}
    + } +
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..030fb2196dc 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,7 @@ right: 0; transform: translateY(-50%) } + +.source-icon { + 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 97fbaf03686..2a4dc110b02 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 @@ -45,6 +45,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { getTestScheduler } from 'jasmine-marbles'; import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { v4 as uuidv4 } from 'uuid'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive'; @@ -52,11 +53,14 @@ import { VocabularyTreeviewComponent } from '../../../../vocabulary-treeview/voc 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 = { @@ -364,13 +368,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(); }); @@ -381,7 +385,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('test001', null, validAuthority, 'test')); 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..180785cf5f3 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 @@ -64,6 +64,7 @@ import { tap, } from 'rxjs/operators'; +import { environment } from '../../../../../../../environments/environment'; 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'; @@ -106,8 +107,11 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false)); click$ = new Subject(); currentValue: any; + previousValue: any; inputValue: any; preloadLevel: number; + authorithyIcons = environment.submission.icons.authority.sourceIcons; + private vocabulary$: Observable; private isHierarchicalVocabulary$: Observable; @@ -293,6 +297,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple modalRef.result.then((result: VocabularyEntryDetail) => { if (result) { this.currentValue = result; + this.previousValue = result; this.dispatchUpdate(result); } }, () => { @@ -323,6 +328,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple .subscribe((formValue: FormFieldMetadataValueObject) => { this.changeLoadingInitialValueStatus(false); this.currentValue = formValue; + this.previousValue = formValue; this.cdr.detectChanges(); }); } else { @@ -331,13 +337,44 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple } else { result = value.value; } + this.currentValue = null; + this.cdr.detectChanges(); this.currentValue = result; + this.previousValue = result; this.cdr.detectChanges(); } } + /** + * 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 { this.subs .filter((sub) => hasValue(sub)) 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..93913a77ad4 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.html @@ -0,0 +1,20 @@ + + @if ((orcidUrl$ | async)) { + + {{ metadataValue }} + + } @else { + {{ metadataValue }} + } + + + @if (hasOrcidBadge()) { + 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..82458a82ce9 --- /dev/null +++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.ts @@ -0,0 +1,61 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstSucceededRemoteDataPayload } 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( + getFirstSucceededRemoteDataPayload(), + map((property: ConfigurationProperty) => + property?.values?.length > 0 ? property.values[0] : 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.html b/src/app/submission/edit/submission-edit.component.html index 9c0e9eae723..40b569c2020 100644 --- a/src/app/submission/edit/submission-edit.component.html +++ b/src/app/submission/edit/submission-edit.component.html @@ -6,5 +6,6 @@ [submissionErrors]="submissionErrors" [item]="item" [collectionModifiable]="collectionModifiable" - [submissionId]="submissionId"> + [submissionId]="submissionId" + [entityType]="entityType">
diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index 2749ebe5684..b00d0bdbb6e 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -51,7 +51,13 @@ describe('SubmissionEditComponent Component', () => { const submissionId = '826'; const route: ActivatedRouteStub = new ActivatedRouteStub(); - const submissionObject: any = mockSubmissionObject; + const submissionObject: any = Object.assign({}, mockSubmissionObject, { + collection: { + ...mockSubmissionObject.collection, + hasMetadata: (_: string) => true, + firstMetadataValue: (_: string) => true, + }, + }); beforeEach(waitForAsync(() => { itemDataService = jasmine.createSpyObj('itemDataService', { diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 9eeb6aecd5e..ded2c60e1ef 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -66,6 +66,11 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { */ public collectionModifiable: boolean | null = null; + /** + * The entity type of the submission + * @type {string} + */ + public entityType: string; /** * The list of submission's sections @@ -154,6 +159,9 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); this.router.navigate(['/mydspace']); } else { + const collection = submissionObjectRD.payload.collection as Collection; + this.entityType = (hasValue(collection) && collection.hasMetadata('dspace.entity.type')) + ? collection.firstMetadataValue('dspace.entity.type') : null; const { errors } = submissionObjectRD.payload; this.submissionErrors = parseSectionErrors(errors); this.submissionId = submissionObjectRD.payload.id.toString(); diff --git a/src/app/submission/form/submission-form.component.html b/src/app/submission/form/submission-form.component.html index b193e551fc9..a8b3b5a5afe 100644 --- a/src/app/submission/form/submission-form.component.html +++ b/src/app/submission/form/submission-form.component.html @@ -36,6 +36,7 @@ @for (object of $any(submissionSections | async); track object) { } diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 7097d63867e..89ca7286df0 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -115,6 +115,12 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { */ @Input() submissionId: string; + /** + * The entity type input used to create a new submission + * @type {string} + */ + @Input() entityType: string; + /** * The configuration id that define this submission * @type {string} diff --git a/src/app/submission/form/themed-submission-form.component.ts b/src/app/submission/form/themed-submission-form.component.ts index af3fe244c8c..20c4d375276 100644 --- a/src/app/submission/form/themed-submission-form.component.ts +++ b/src/app/submission/form/themed-submission-form.component.ts @@ -31,7 +31,9 @@ export class ThemedSubmissionFormComponent extends ThemedComponent (this.collectionId), deps: [] }, { provide: 'sectionDataProvider', useFactory: () => (this.sectionData), deps: [] }, { provide: 'submissionIdProvider', useFactory: () => (this.submissionId), deps: [] }, + { provide: 'entityType', useFactory: () => (this.entityType), deps: [] }, ], parent: this.injector, }); diff --git a/src/app/submission/sections/container/themed-section-container.component.ts b/src/app/submission/sections/container/themed-section-container.component.ts index f8bab3b932c..b904095c064 100644 --- a/src/app/submission/sections/container/themed-section-container.component.ts +++ b/src/app/submission/sections/container/themed-section-container.component.ts @@ -15,8 +15,9 @@ export class ThemedSubmissionSectionContainerComponent extends ThemedComponent + + + +
+ {{frontendUrl}}/ + @if (formModel) { + + } +
+ @if (isEditItemScope && !!customSectionData && !!redirectedUrls) { +
+ @if (redirectedUrls.length > 0) { +

{{'submission.sections.custom-url.label.previous-urls' | translate}}

+ } +
    + @for (redirectedUrl of redirectedUrls; let i = $index; track redirectedUrl) { +
  • +
    + {{frontendUrl+'/'+redirectedUrl}} +
    + + +
  • + } +
+
+ } + diff --git a/src/app/submission/sections/custom-url/submission-section-custom-url.component.scss b/src/app/submission/sections/custom-url/submission-section-custom-url.component.scss new file mode 100644 index 00000000000..3bd4d0f522e --- /dev/null +++ b/src/app/submission/sections/custom-url/submission-section-custom-url.component.scss @@ -0,0 +1,18 @@ +.options-select-menu { + max-height: 25vh; +} + +.list-group{ + max-width: 600px; +} + +.list-group-item{ + width: 80%; + display: flex; + justify-content: space-between; +} + +.list-item{ + align-items: center; + display: flex; +} diff --git a/src/app/submission/sections/custom-url/submission-section-custom-url.component.spec.ts b/src/app/submission/sections/custom-url/submission-section-custom-url.component.spec.ts new file mode 100644 index 00000000000..1ee130e6518 --- /dev/null +++ b/src/app/submission/sections/custom-url/submission-section-custom-url.component.spec.ts @@ -0,0 +1,225 @@ +import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormControl, + FormGroup, +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { JsonPatchOperationsBuilder } from '@dspace/core/json-patch/builder/json-patch-operations-builder'; +import { WorkspaceitemSectionCustomUrlObject } from '@dspace/core/submission/models/workspaceitem-section-custom-url.model'; +import { + DynamicFormControlEvent, + DynamicInputModel, +} from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SubmissionSectionCustomUrlComponent } from './submission-section-custom-url.component'; +import SpyObj = jasmine.SpyObj; +import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; +import { SectionsType } from '@dspace/core/submission/sections-type'; + +import { FormComponent } from '../../../shared/form/form.component'; +import { getMockFormBuilderService } from '../../../shared/form/testing/form-builder-service.mock'; +import { getMockFormOperationsService } from '../../../shared/form/testing/form-operations-service.mock'; +import { getMockFormService } from '../../../shared/form/testing/form-service.mock'; + +describe('SubmissionSectionCustomUrlComponent', () => { + + let component: SubmissionSectionCustomUrlComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const builderService: SpyObj = getMockFormBuilderService() as SpyObj; + const sectionFormOperationsService: SpyObj = getMockFormOperationsService() as SpyObj; + const customUrlData = { + 'url': 'test', + 'redirected-urls': [ + 'redirected1', + 'redirected2', + ], + } as WorkspaceitemSectionCustomUrlObject; + + let formService: any; + + const sectionService = jasmine.createSpyObj('sectionService', { + getSectionState: of({ data: customUrlData }), + setSectionStatus: () => undefined, + updateSectionData: (submissionId, sectionId, updatedData) => { + component.sectionData.data = updatedData; + }, + getSectionServerErrors: of([]), + checkSectionErrors: () => undefined, + }); + + const sectionObject: SectionDataObject = { + config: 'test config', + mandatory: true, + opened: true, + data: {}, + errorsToShow: [], + serverValidationErrors: [], + header: 'test header', + id: 'test section id', + sectionType: SectionsType.CustomUrl, + sectionVisibility: null, + }; + + const operationsBuilder = jasmine.createSpyObj('operationsBuilder', { + add: undefined, + remove: undefined, + replace: undefined, + }); + + const submissionService = jasmine.createSpyObj('SubmissionService', { + getSubmissionScope: jasmine.createSpy('getSubmissionScope'), + }); + + const changeEvent: DynamicFormControlEvent = { + $event: { + + type: 'change', + }, + context: null, + control: new FormControl({ + errors: null, + pristine: false, + status: 'VALID', + touched: true, + value: 'test-url', + _updateOn: 'change', + }), + group: new FormGroup({}), + model: new DynamicInputModel({ + additional: null, + asyncValidators: null, + controlTooltip: null, + errorMessages: null, + hidden: false, + hint: null, + id: 'url', + label: 'Url', + labelTooltip: null, + name: 'url', + relations: [], + required: false, + tabIndex: null, + updateOn: null, + }), + type: 'change', + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + SubmissionSectionCustomUrlComponent, + MockComponent(FormComponent), + ], + providers: [ + { provide: SectionsService, useValue: sectionService }, + { provide: SubmissionService, useValue: submissionService }, + { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, + { provide: FormBuilderService, useValue: builderService }, + { provide: SectionFormOperationsService, useValue: sectionFormOperationsService }, + { provide: FormService, useValue: getMockFormService() }, + { provide: 'entityType', useValue: 'Person' }, + { provide: 'collectionIdProvider', useValue: 'test collection id' }, + { provide: 'sectionDataProvider', useValue: Object.assign({}, sectionObject) }, + { provide: 'submissionIdProvider', useValue: 'test submission id' }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionCustomUrlComponent); + component = fixture.componentInstance; + + formService = TestBed.inject(FormService); + formService.validateAllFormFields.and.callFake(() => null); + formService.isValid.and.returnValue(of(true)); + formService.getFormData.and.returnValue(of({})); + + de = fixture.debugElement; + fixture.detectChanges(); + }); + + afterEach(() => { + sectionFormOperationsService.getFieldValueFromChangeEvent.calls.reset(); + operationsBuilder.replace.calls.reset(); + operationsBuilder.add.calls.reset(); + }); + + it('should display custom url section', () => { + expect(de.query(By.css('.custom-url'))).toBeTruthy(); + }); + + it('should have the right url formed', () => { + expect(component.frontendUrl).toContain('/entities/person'); + }); + + it('formModel should have length of 1', () => { + expect(component.formModel.length).toEqual(1); + }); + + it('formModel should have 1 DynamicInputModel', () => { + expect(component.formModel[0] instanceof DynamicInputModel).toBeTrue(); + }); + + it('if edit item true should show redirected urls managment', () => { + expect(de.query(By.css('.previous-urls'))).toBeFalsy(); + }); + + it('if edit item true should show redirected urls managment', () => { + component.isEditItemScope = true; + fixture.detectChanges(); + expect(de.query(By.css('.previous-urls'))).toBeTruthy(); + }); + + it('when input changed it should call operationsBuilder replace', () => { + component.onChange(changeEvent); + fixture.detectChanges(); + + expect(operationsBuilder.replace).toHaveBeenCalled(); + }); + + it('when input changed and is not empty it should call operationsBuilder add function for redirected urls', () => { + component.isEditItemScope = true; + component.customSectionData.url = 'url'; + sectionFormOperationsService.getFieldValueFromChangeEvent.and.returnValue(new FormFieldMetadataValueObject('testurl')); + component.onChange(changeEvent); + fixture.detectChanges(); + + expect(operationsBuilder.add).toHaveBeenCalled(); + }); + + it('when input changed and is empty it should not call operationsBuilder add function for redirected urls', () => { + component.isEditItemScope = true; + component.customSectionData.url = 'url'; + sectionFormOperationsService.getFieldValueFromChangeEvent.and.returnValue(new FormFieldMetadataValueObject('')); + component.onChange(changeEvent); + fixture.detectChanges(); + + expect(operationsBuilder.add).not.toHaveBeenCalled(); + }); + + + it('when remove button clicked it should call operationsBuilder remove function for redirected urls', () => { + component.remove(1); + fixture.detectChanges(); + expect(operationsBuilder.remove).toHaveBeenCalled(); + }); + +}); diff --git a/src/app/submission/sections/custom-url/submission-section-custom-url.component.ts b/src/app/submission/sections/custom-url/submission-section-custom-url.component.ts new file mode 100644 index 00000000000..706723516f5 --- /dev/null +++ b/src/app/submission/sections/custom-url/submission-section-custom-url.component.ts @@ -0,0 +1,307 @@ +import { + AfterViewInit, + Component, + Inject, + ViewChild, +} from '@angular/core'; +import { + AbstractControl, + ValidationErrors, +} from '@angular/forms'; +import { JsonPatchOperationPathCombiner } from '@dspace/core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '@dspace/core/json-patch/builder/json-patch-operations-builder'; +import { SubmissionSectionError } from '@dspace/core/submission/models/submission-section-error.model'; +import { SubmissionSectionObject } from '@dspace/core/submission/models/submission-section-object.model'; +import { WorkspaceitemSectionCustomUrlObject } from '@dspace/core/submission/models/workspaceitem-section-custom-url.model'; +import { SectionsType } from '@dspace/core/submission/sections-type'; +import { SubmissionScopeType } from '@dspace/core/submission/submission-scope-type'; +import { URLCombiner } from '@dspace/core/url-combiner/url-combiner'; +import { + hasValue, + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicInputModel, +} from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + combineLatest as observableCombineLatest, + Observable, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + take, +} from 'rxjs/operators'; + +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; + + +/** + * This component represents the submission section to select the Creative Commons license. + */ +@Component({ + selector: 'ds-submission-section-custom-url', + templateUrl: './submission-section-custom-url.component.html', + styleUrls: ['./submission-section-custom-url.component.scss'], + imports: [ + FormComponent, + TranslateModule, + ], +}) +export class SubmissionSectionCustomUrlComponent extends SectionModelComponent implements AfterViewInit { + + /** + * The form id + * @type {string} + */ + public formId: string; + + /** + * A boolean representing if this section is loading + * @type {boolean} + */ + public isLoading = true; + + /** + * The [JsonPatchOperationPathCombiner] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + + /** + * The list of Subscriptions this component subscribes to. + */ + private subs: Subscription[] = []; + + /** + * The current custom section data + */ + customSectionData: WorkspaceitemSectionCustomUrlObject; + + /** + * A list of all dynamic input models + */ + formModel: DynamicFormControlModel[]; + + /** + * Full path of the item page + */ + frontendUrl: string; + + /** + * Represents if the section is used in the editItem Scope of submission + */ + isEditItemScope = false; + + /** + * Represents the list of redirected urls to be managed + */ + redirectedUrls: string[] = []; + + private readonly errorMessagePrefix = 'error.validation.custom-url.'; + + /** + * The FormComponent reference + */ + @ViewChild('formRef') public formRef: FormComponent; + + constructor( + protected sectionService: SectionsService, + protected operationsBuilder: JsonPatchOperationsBuilder, + protected formOperationsService: SectionFormOperationsService, + protected formService: FormService, + protected submissionService: SubmissionService, + @Inject('entityType') public entityType: string, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string, + ) { + super( + injectedCollectionId, + injectedSectionData, + injectedSubmissionId, + ); + } + + /** + * Unsubscribe from all subscriptions + */ + onSectionDestroy(): void { + this.subs.forEach((subscription) => subscription.unsubscribe()); + } + + /** + * Initialize the section. + * Define if submission is in EditItem scope to allow user to manage redirect urls + * Setup the full path of the url that will be seen by the users + * Get current information and build the form + */ + onSectionInit(): void { + this.formId = this.formService.getUniqueId(this.sectionData.id); + this.setSubmissionScope(); + this.frontendUrl = new URLCombiner(window.location.origin, '/entities', encodeURIComponent(this.entityType.toLowerCase())).toString(); + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + + this.sectionService.getSectionState(this.submissionId, this.sectionData.id, SectionsType.CustomUrl).pipe( + take(1), + ).subscribe((state: SubmissionSectionObject) => { + this.initForm(state.data as WorkspaceitemSectionCustomUrlObject); + this.subscriptionOnSectionChange(); + }); + } + + setSubmissionScope() { + if (this.submissionService.getSubmissionScope() === SubmissionScopeType.EditItem) { + this.isEditItemScope = true; + } + } + + + /** + * Get section status + * + * @return Observable + * the section status + */ + protected getSectionStatus(): Observable { + const formStatus$ = this.formService.isValid(this.formId); + const serverValidationStatus$ = this.sectionService.getSectionServerErrors(this.submissionId, this.sectionData.id).pipe( + map((validationErrors) => isEmpty(validationErrors)), + ); + + return observableCombineLatest([formStatus$, serverValidationStatus$]).pipe( + map(([formValidation, serverSideValidation]: [boolean, boolean]) => { + return isEmpty(this.customSectionData.url) || formValidation && serverSideValidation; + }), + ); + } + + /** + * Initialize form model + * + * @param sectionData + * the section data retrieved from the server + */ + initForm(sectionData: WorkspaceitemSectionCustomUrlObject): void { + this.formModel = [ + new DynamicInputModel({ + id: 'url', + name: 'url', + value: sectionData.url, + placeholder: 'submission.sections.custom-url.url.placeholder', + errorMessages: { + 'conflict': 'error.validation.custom-url.conflict', + 'empty': 'error.validation.custom-url.empty', + 'invalid-characters': 'error.validation.custom-url.invalid-characters', + }, + }), + ]; + this.updateSectionData(sectionData); + } + + customUrlValidator = (_: AbstractControl): ValidationErrors | null => { + if (this.sectionData.errorsToShow?.length) { + const urlErrors = this.sectionData.errorsToShow.map((error) => + error.message.replace(this.errorMessagePrefix, '')); + const validationErrors: ValidationErrors = {}; + + urlErrors.forEach((error) => { + validationErrors[error] = true; + }); + return validationErrors; + } + return null; + }; + + /** + * Update control status + * @param addValidator + */ + updateControlStatus(addValidator = false): void { + const control = this.formRef?.formGroup?.get('url'); + if (control) { + if (addValidator) { + control.addValidators(this.customUrlValidator); + // reset errors on user input + this.subs.push(control.valueChanges.subscribe(() => { + control.setErrors(null); + })); + } + control.updateValueAndValidity({ onlySelf: true, emitEvent: false }); + } + } + + ngAfterViewInit(): void { + this.updateControlStatus(true); + } + + /** + * When an information is changed build the formOperations + * If the submission scope is in EditItem also manage redirected-urls formOperations + */ + onChange(event: DynamicFormControlEvent): void { + const path = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event); + const metadataValue = this.formOperationsService.getFieldValueFromChangeEvent(event); + this.operationsBuilder.replace(this.pathCombiner.getPath(path), metadataValue.value, true); + + if (isNotEmpty(metadataValue.value) && this.isEditItemScope && hasValue(this.customSectionData.url)) { + // Utilizing submissionCustomUrl.url as the last value saved we can add to the redirected-urls + this.operationsBuilder.add(this.pathCombiner.getPath(['redirected-urls']), this.customSectionData.url, false, true); + } + } + + /** + * When removing a redirected url build the formOperations + */ + remove(index: number): void { + this.operationsBuilder.remove(this.pathCombiner.getPath(['redirected-urls', index.toString()])); + this.redirectedUrls.splice(index, 1); + } + + /** + * Update section data + * + * @param sectionData + */ + private updateSectionData(sectionData: WorkspaceitemSectionCustomUrlObject): void { + this.customSectionData = sectionData; + // Remove sealed object so we can remove urls from array + if (hasValue(sectionData['redirected-urls']) && isNotEmpty(sectionData['redirected-urls'])) { + this.redirectedUrls = [...sectionData['redirected-urls']]; + } else { + this.redirectedUrls = []; + } + } + + private subscriptionOnSectionChange(): void { + this.subs.push( + this.sectionService.getSectionState(this.submissionId, this.sectionData.id, SectionsType.CustomUrl).pipe( + filter((sectionState) => { + return isNotEmpty(sectionState) && (isNotEmpty(sectionState.data) || isNotEmpty(sectionState.errorsToShow)); + }), + distinctUntilChanged(), + ).subscribe((state: SubmissionSectionObject) => { + this.updateSectionData(state.data as WorkspaceitemSectionCustomUrlObject); + const errors: SubmissionSectionError[] = state.errorsToShow; + + if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errorsToShow)) { + this.sectionService.checkSectionErrors(this.submissionId, this.sectionData.id, this.formId, errors, this.sectionData.errorsToShow); + this.sectionData.errorsToShow = errors; + this.updateControlStatus(true); + } + }), + ); + } +} diff --git a/src/app/submission/sections/sections-decorator.ts b/src/app/submission/sections/sections-decorator.ts index 5f584196cd6..471889d3b42 100644 --- a/src/app/submission/sections/sections-decorator.ts +++ b/src/app/submission/sections/sections-decorator.ts @@ -2,6 +2,7 @@ import { SectionsType } from '@dspace/core/submission/sections-type'; import { SubmissionSectionAccessesComponent } from './accesses/section-accesses.component'; import { SubmissionSectionCcLicensesComponent } from './cc-license/submission-section-cc-licenses.component'; +import { SubmissionSectionCustomUrlComponent } from './custom-url/submission-section-custom-url.component'; import { SubmissionSectionDuplicatesComponent } from './duplicates/section-duplicates.component'; import { SubmissionSectionFormComponent } from './form/section-form.component'; import { SubmissionSectionIdentifiersComponent } from './identifiers/section-identifiers.component'; @@ -21,6 +22,7 @@ submissionSectionsMap.set(SectionsType.SubmissionForm, SubmissionSectionFormComp submissionSectionsMap.set(SectionsType.Identifiers, SubmissionSectionIdentifiersComponent); submissionSectionsMap.set(SectionsType.CoarNotify, SubmissionSectionCoarNotifyComponent); submissionSectionsMap.set(SectionsType.Duplicates, SubmissionSectionDuplicatesComponent); +submissionSectionsMap.set(SectionsType.CustomUrl, SubmissionSectionCustomUrlComponent); /** * @deprecated diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 3774f7e89ea..09e9d5d9cc0 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -128,7 +128,6 @@ export class SectionsService { // Iterate over the previous error list prevErrors.forEach((error: SubmissionSectionError) => { const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); - errorPaths.forEach((path: SectionErrorPath) => { if (path.fieldId) { if (!dispatchedErrors.includes(path.fieldId)) { diff --git a/src/app/submission/utils/submission.mock.ts b/src/app/submission/utils/submission.mock.ts index 991c6afc943..35b97993c4c 100644 --- a/src/app/submission/utils/submission.mock.ts +++ b/src/app/submission/utils/submission.mock.ts @@ -404,6 +404,11 @@ export const mockSubmissionObject = { language: null, value: 'Collection of Sample Items', }, + { + key: 'dspace.entity.type', + language: null, + value: 'Entity type of Sample Collection', + }, ], _links: { license: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license' }, diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 159926bf032..5b23f94a1a5 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1923,10 +1923,16 @@ "error.top-level-communities": "Error fetching top-level communities", - "error.validation.license.notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", + "error.validation.license.required": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", "error.validation.cclicense.required": "You must grant this cclicense to complete your submission. If you are unable to grant the cclicense at this time, you may save your work and return later or remove the submission.", + "error.validation.custom-url.conflict": "The custom url has been already used, please try with a new one.", + + "error.validation.custom-url.empty": "The custom url is required and cannot be empty.", + + "error.validation.custom-url.invalid-characters": "The custom url contains invalid characters.", + "error.validation.pattern": "This input is restricted by the current pattern: {{ pattern }}.", "error.validation.filerequired": "The file upload is mandatory", @@ -2081,6 +2087,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", @@ -2559,6 +2599,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", @@ -3243,6 +3285,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", @@ -5544,6 +5588,8 @@ "submission.sections.submit.progressbar.CClicense": "Creative commons license", + "submission.sections.submit.progressbar.CustomUrlStep": "Custom Url", + "submission.sections.submit.progressbar.describe.recycle": "Recycle", "submission.sections.submit.progressbar.describe.stepcustom": "Describe", @@ -5654,6 +5700,12 @@ "submission.sections.upload.upload-successful": "Upload successful", + "submission.sections.custom-url.label.previous-urls": "Previous Urls", + + "submission.sections.custom-url.alert.info": "Define here a custom URL which will be used to reach the item instead of using an internal randomly generated UUID identifier. ", + + "submission.sections.custom-url.url.placeholder": "Custom URL", + "submission.sections.accesses.form.discoverable-description": "When checked, this item will be discoverable in search/browse. When unchecked, the item will only be available via a direct link and will never appear in search/browse.", "submission.sections.accesses.form.discoverable-label": "Discoverable", @@ -6312,6 +6364,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", @@ -7213,4 +7267,54 @@ "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.OrgUnit.dc.title": "Fullname", + + "metadata-link-view.popover.label.OrgUnit.organization.identifier.ror": "ROR", + + "metadata-link-view.popover.label.OrgUnit.crisou.director": "Director", + + "metadata-link-view.popover.label.OrgUnit.organization.parentOrganization": "Parent Organization", + + "metadata-link-view.popover.label.OrgUnit.dc.description.abstract": "Description", + + "metadata-link-view.popover.label.Project.dc.title": "Title", + + "metadata-link-view.popover.label.Project.oairecerif.project.status": "Status", + + "metadata-link-view.popover.label.Project.dc.description.abstract": "Abstract", + + "metadata-link-view.popover.label.Funding.dc.title": "Title", + + "metadata-link-view.popover.label.Funding.oairecerif.funder": "Funder", + + "metadata-link-view.popover.label.Funding.oairecerif.fundingProgram": "Funding program", + + "metadata-link-view.popover.label.Funding.dc.description.abstract": "Abstract", + + "metadata-link-view.popover.label.Publication.dc.title": "Title", + + "metadata-link-view.popover.label.Publication.dc.identifier.doi": "DOI", + + "metadata-link-view.popover.label.Publication.dc.identifier.uri": "URL", + + "metadata-link-view.popover.label.Publication.dc.description.abstract": "Abstract", + + "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 a685c7cf3fd..f532d6e6815 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'; @@ -251,6 +260,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', + }, + ], }, }, }; @@ -642,4 +677,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 bd456f328cd..0ee0640d399 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -476,4 +476,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,