diff --git a/config/config.example.yml b/config/config.example.yml index 6f6c67d1ae6..51e66c8e150 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -707,7 +707,7 @@ accessibility: cookieExpirationDuration: 7 # Configuration for layout customization of metadata rendering in Item page -# Currently only the authority reference config is available, more will follow with the integration of the so called CRIS layout. +# Currently only the authority reference config is available, more will follow with the integration of the so called Dynamic layout. layout: # Configuration of icons and styles to be used for each authority controlled link authorityRef: @@ -757,6 +757,54 @@ layout: - name: checksum type: attribute + # Configuration for URN (Uniform Resource Name) identifier resolution + # Maps identifier types (e.g., DOI, Handle, Scopus) to their base URLs for creating resolvable links + urn: + - name: doi + baseUrl: 'https://doi.org/' + - name: hdl + baseUrl: 'https://hdl.handle.net/' + - name: scopus + baseUrl: 'https://www.scopus.com/authid/detail.uri?authorId=' + - name: researcherid + baseUrl: 'http://www.researcherid.com/rid/' + - name: mailto + baseUrl: 'mailto:' + + # Item page layout configuration for different entity types + # Defines the visual layout orientation (horizontal/vertical) for various entity types in the item page detail view + itemPage: + OrgUnit: + orientation: vertical + Project: + orientation: vertical + default: + orientation: horizontal + + # Metadata box rendering configuration + # Defines default CSS column styles for metadata labels and values in item page detail view + metadataBox: + # CSS classes for metadata label column (default: 'col-3' = 25% width) + defaultMetadataLabelColStyle: col-3 + # CSS classes for metadata value column (default: 'col-9' = 75% width) + defaultMetadataValueColStyle: col-9 + + # Collections box rendering configuration + # Defines default CSS column styles for collections and inline display settings + collectionsBox: + # CSS classes for collection label column (default: 'col-3 fw-bold' = 25% width, bold text) + defaultCollectionsLabelColStyle: col-3 fw-bold + # CSS classes for collection value column (default: 'col-9' = 75% width) + defaultCollectionsValueColStyle: col-9 + # CSS classes for collection row styling + # defaultCollectionsRowStyle: '' + # Whether to display collections inline (true) or in a block layout (false) + isInline: true + # Key/Value map to define metadata holding the style information for each entity type + dynamicRefStyleMetadata: + default: 'dspace.entity.style' + Publication: 'example.entity.style' + # Configuration for customization of search results searchResults: # Metadata fields to be displayed in the search results under the standard ones diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 8511f17dc00..1712d6d4c62 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -64,6 +64,7 @@ const initialState = { export function getMockLocaleService(): LocaleService { return jasmine.createSpyObj('LocaleService', { setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode'), + getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'), }); } diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index 5bec3a1e3ff..8019657fa06 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -20,7 +20,7 @@ import { RequestService } from '@dspace/core/data/request.service'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { Collection } from '@dspace/core/shared/collection.model'; import { ItemType } from '@dspace/core/shared/item-relationships/item-type.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { getFirstSucceededRemoteListPayload } from '@dspace/core/shared/operators'; import { hasNoValue, diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 10882908583..8ce3f61ed12 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { hasValue, isEmpty, diff --git a/src/app/core/browse/search-manager.ts b/src/app/core/browse/search-manager.ts index 84925c1c153..b82ba9de4b4 100644 --- a/src/app/core/browse/search-manager.ts +++ b/src/app/core/browse/search-manager.ts @@ -25,7 +25,7 @@ import { DSpaceObject } from '../shared/dspace-object.model'; import { FollowLinkConfig } from '../shared/follow-link-config.model'; import { Item } from '../shared/item.model'; import { ITEM } from '../shared/item.resource-type'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; import { Metadata } from '../shared/metadata.utils'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { PaginatedSearchOptions } from '../shared/search/models/paginated-search-options.model'; diff --git a/src/app/core/browse/search.manager.spec.ts b/src/app/core/browse/search.manager.spec.ts index c69393d21fe..b96d7f9e27f 100644 --- a/src/app/core/browse/search.manager.spec.ts +++ b/src/app/core/browse/search.manager.spec.ts @@ -8,7 +8,7 @@ import { FindListOptions } from '../data/find-list-options.model'; import { FollowLinkConfig } from '../shared/follow-link-config.model'; import { Item } from '../shared/item.model'; import { ITEM } from '../shared/item.resource-type'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; import { AUTHORITY_REFERENCE } from '../shared/metadata.utils'; import { createPaginatedList } from '../testing/utils.test'; import { diff --git a/src/app/core/cookies/browser-orejime.service.spec.ts b/src/app/core/cookies/browser-orejime.service.spec.ts index 7a293109ae1..3a993373ade 100644 --- a/src/app/core/cookies/browser-orejime.service.spec.ts +++ b/src/app/core/cookies/browser-orejime.service.spec.ts @@ -16,7 +16,7 @@ import { ConfigurationDataService } from '../data/configuration-data.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { EPerson } from '../eperson/models/eperson.model'; import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; import { getMockTranslateService } from '../testing/translate.service.mock'; import { createFailedRemoteDataObject$, diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts index ea7d3b51996..a71eed89871 100644 --- a/src/app/core/data-services-map.ts +++ b/src/app/core/data-services-map.ts @@ -16,6 +16,8 @@ import { GROUP } from './eperson/models/group.resource-type'; import { WORKFLOWITEM } from './eperson/models/workflowitem.resource-type'; import { WORKSPACEITEM } from './eperson/models/workspaceitem.resource-type'; import { FEEDBACK } from './feedback/models/feedback.resource-type'; +import { SECTION } from './layout/models/section.resource-type'; +import { TAB } from './layout/models/tab.resource-type'; import { METADATA_FIELD } from './metadata/metadata-field.resource-type'; import { METADATA_SCHEMA } from './metadata/metadata-schema.resource-type'; import { QUALITY_ASSURANCE_EVENT_OBJECT } from './notifications/qa/models/quality-assurance-event-object.resource-type'; @@ -142,4 +144,6 @@ export const LAZY_DATA_SERVICES: LazyDataServicesMap = new Map([ [AUDIT.value, () => import('./data/audit-data.service').then(m => m.AuditDataService)], [EditItem.type.value, () => import('./submission/edititem-data.service').then(m => m.EditItemDataService)], [METADATA_SECURITY_TYPE.value, () => import('./submission/metadatasecurityconfig-data.service').then(m => m.MetadataSecurityConfigurationService)], + [SECTION.value, () => import('./layout/section-data.service').then(m => m.SectionDataService)], + [TAB.value, () => import('./layout/tab-data.service').then(m => m.TabDataService)], ]); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 42d02e96224..7472574c7dc 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -65,6 +65,13 @@ import { } from './request.models'; import { RequestService } from './request.service'; +/** + * Filter for metadata value to be used for rest API + */ +export interface MetadataFilter { + metadataName: string; + metadataValue: string; +} /** * A service to retrieve {@link Bitstream}s from the REST API */ @@ -379,4 +386,152 @@ export class BitstreamDataService extends IdentifiableDataService imp return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => observableCombineLatest(bitstreams.map((bitstream: Bitstream) => this.invalidateByHref(bitstream._links.self.href)))); } + /** + * Returns an observable of {@link RemoteData} of a {@link Bitstream} that is not marked + * hidden (that hasn't got the metadata `bitstream.hide` or its value is not true/yes). + * resolve {@link HALLink}s of the object + * + * @param uuid The item UUID to retrieve bitstreams from + * @param bundlename Bundle type of the bitstreams + * @param metadataFilters Array of object we want to filter by + * @param options The {@link FindListOptions} for the request + * @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 + */ + findShowableBitstreamsByItem( + uuid: string, + bundlename: string, + metadataFilters: MetadataFilter[], + options: FindListOptions = {}, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + const searchParams = []; + searchParams.push(new RequestParam('uuid', uuid)); + searchParams.push(new RequestParam('name', bundlename)); + + metadataFilters.forEach((entry: MetadataFilter) => { + searchParams.push(new RequestParam('filterMetadata', entry.metadataName)); + searchParams.push(new RequestParam('filterMetadataValue', entry.metadataValue)); + }); + + const hrefObs = this.getSearchByHref( + 'showableByItem', + { searchParams }, + ...linksToFollow, + ); + + return this.findListByHref( + hrefObs, + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow, + ); + } + + + /** + * Returns an observable of {@link RemoteData} of a {@link Bitstream}, based on a handle and an + * optional sequenceId or filename, with a list of {@link FollowLinkConfig}, to automatically + * resolve {@link HALLink}s of the object + * + * @param uuid The item UUID to retrieve bitstreams from + * @param bundlename Bundle type of the bitstreams + * @param metadataFilters Array of object we want to filter by + * @param options The {@link FindListOptions} for the request + * @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 + */ + findByItem( + uuid: string, + bundlename: string, + metadataFilters: MetadataFilter[], + options: FindListOptions, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + const searchParams = []; + searchParams.push(new RequestParam('uuid', uuid)); + searchParams.push(new RequestParam('name', bundlename)); + + metadataFilters.forEach((entry: MetadataFilter) => { + searchParams.push(new RequestParam('filterMetadata', entry.metadataName)); + searchParams.push(new RequestParam('filterMetadataValue', entry.metadataValue)); + }); + + const hrefObs = this.getSearchByHref( + 'byItemId', + { searchParams }, + ...linksToFollow, + ); + + return this.findListByHref( + hrefObs, + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow, + ); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link Bitstream} that is not marked + * hidden (that hasn't got the metadata `bitstream.hide` or its value is not true/yes). + * resolve {@link HALLink}s of the object + * + * @param uuid The item UUID to retrieve bitstreams from + * @param bundlename Bundle type of the bitstreams + * @param metadataFilters Array of object we want to filter by + * @param options The {@link FindListOptions} for the request + * @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 + */ + showableByItem( + uuid: string, + bundlename: string, + metadataFilters: MetadataFilter[], + options: FindListOptions, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + const searchParams = []; + searchParams.push(new RequestParam('uuid', uuid)); + searchParams.push(new RequestParam('name', bundlename)); + + metadataFilters.forEach((entry: MetadataFilter) => { + searchParams.push(new RequestParam('filterMetadata', entry.metadataName)); + searchParams.push(new RequestParam('filterMetadataValue', entry.metadataValue)); + }); + + const hrefObs = this.getSearchByHref( + 'showableByItem', + { searchParams }, + ...linksToFollow, + ); + + return this.findListByHref( + hrefObs, + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow, + ); + } + } diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index 642cfc6dbcb..b72d8ce70da 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -4,10 +4,7 @@ import { hasValue } from '@dspace/shared/utils/empty.util'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { - MetadataMap, - MetadataValue, -} from '../shared/metadata.models'; +import MetadataValue, { MetadataMap } from '../shared/metadata.models'; import { SearchObjects } from '../shared/search/models/search-objects.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; diff --git a/src/app/core/data/relationship-data.service.spec.ts b/src/app/core/data/relationship-data.service.spec.ts index 7eaf4067544..7c714cad39b 100644 --- a/src/app/core/data/relationship-data.service.spec.ts +++ b/src/app/core/data/relationship-data.service.spec.ts @@ -12,7 +12,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model'; import { PageInfo } from '../shared/page-info.model'; import { HALEndpointServiceStub } from '../testing/hal-endpoint-service.stub'; diff --git a/src/app/core/data/relationship-data.service.ts b/src/app/core/data/relationship-data.service.ts index 6c511d89236..1d92232b0d1 100644 --- a/src/app/core/data/relationship-data.service.ts +++ b/src/app/core/data/relationship-data.service.ts @@ -44,7 +44,7 @@ import { Item } from '../shared/item.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { ReorderableRelationship } from '../shared/item-relationships/reorderable-relationship'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; import { ItemMetadataRepresentation } from '../shared/metadata-representation/item/item-metadata-representation.model'; import { MetadataRepresentation } from '../shared/metadata-representation/metadata-representation.model'; import { MetadatumRepresentation } from '../shared/metadata-representation/metadatum/metadatum-representation.model'; diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index bd0f63f3e98..4e2bb307df0 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -4,10 +4,7 @@ import { hasValue } from '@dspace/shared/utils/empty.util'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { - MetadataMap, - MetadataValue, -} from '../shared/metadata.models'; +import MetadataValue, { MetadataMap } from '../shared/metadata.models'; import { SearchObjects } from '../shared/search/models/search-objects.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; diff --git a/src/app/core/layout/models/box.model.ts b/src/app/core/layout/models/box.model.ts new file mode 100644 index 00000000000..59dfb4fb000 --- /dev/null +++ b/src/app/core/layout/models/box.model.ts @@ -0,0 +1,170 @@ +/** + * Defines a metadata group within a layout field, grouping related metadata entries. + */ +export interface MetadataGroup { + /** The leading metadata key that acts as the group heading. */ + leading: string; + /** Array of layout fields within this group. */ + elements: LayoutField[]; +} + +/** + * Configuration for bitstream-related rendering in a layout field. + */ +export interface LayoutBitstream { + /** The bundle name to filter bitstreams from. */ + bundle: string; + /** The metadata field used to identify the bitstream. */ + metadataField: string; + /** The metadata value to match for selecting the bitstream. */ + metadataValue: string; +} + +/** + * Enum of layout field types that determine how a field is rendered. + */ +export enum LayoutFieldType { + METADATA = 'METADATA', + METADATAGROUP = 'METADATAGROUP', + BITSTREAM = 'BITSTREAM' +} + +/** + * Describes a single renderable field within a metadata box row. + * Contains the metadata key, rendering type, and styling options. + */ +export interface LayoutField { + /** The metadata key to display (e.g., 'dc.title', 'dc.contributor.author'). */ + metadata?: string; + /** Bitstream configuration if this field renders a bitstream. */ + bitstream?: LayoutBitstream; + /** i18n label key for the field. */ + label?: string; + /** The rendering type identifier (e.g., 'text', 'link', 'date', 'thumbnail'). */ + rendering: string; + /** The field type discriminator. */ + fieldType: LayoutFieldType | string; + /** CSS classes for the overall field container. */ + style?: string; + /** CSS classes for the label column. */ + styleLabel?: string; + /** CSS classes for the value column. */ + styleValue?: string; + /** Nested metadata group configuration if fieldType is METADATAGROUP. */ + metadataGroup?: MetadataGroup; + /** Whether to render the label as a heading element. */ + labelAsHeading: boolean; + /** Whether to render multiple values inline (comma-separated) rather than stacked. */ + valuesInline: boolean; +} + +/** + * Configuration for a metadata-type box containing rows of metadata fields. + */ +export interface MetadataBoxConfiguration extends BoxConfiguration { + /** Unique identifier for this box configuration. */ + id: string; + /** Array of rows within the metadata box. */ + rows: MetadataBoxRow[]; +} + +/** + * Base interface for all box configurations. + */ +export interface BoxConfiguration { + /** The box type discriminator. */ + type: string; +} + +/** + * Configuration for a relation-type box that displays related items via a discovery search. + */ +export interface RelationBoxConfiguration extends BoxConfiguration { + /** The discovery configuration name used to find related items. */ + 'discovery-configuration': string; +} + +/** + * Configuration for a metrics-type box displaying item-level metrics. + */ +export interface MetricsBoxConfiguration extends BoxConfiguration { + /** Maximum number of columns for metrics display (null for unlimited). */ + maxColumns: null; + /** Array of metric type identifiers to display. */ + metrics: string[]; +} + +/** + * A cell within a metadata box row, containing layout fields. + */ +export interface MetadataBoxCell { + /** CSS classes applied to the cell element. */ + style: string; + /** Array of fields rendered within this cell. */ + fields: LayoutField[]; +} + +/** + * A row within a metadata box configuration. + */ +export interface MetadataBoxRow { + /** CSS classes applied to the row element. */ + style: string; + /** Array of cells within this row. */ + cells: MetadataBoxCell[]; +} + +/** + * Model representing a box in the dynamic layout system. + * + * A box is a configurable content container within a tab's cell. It defines what type of + * content to render (metadata, relations, metrics, collections, IIIF viewer, versioning) + * and provides styling, security, and collapsibility settings. + */ +export class DynamicLayoutBox { + + /** + * The numeric identifier of this box. + */ + id: number; + + /** Short identifier name for this box. */ + shortname: string; + + /** i18n key or plain text for the box header. */ + header: string; + + /** The entity type this box belongs to. */ + entityType: string; + + /** Whether the box starts collapsed. */ + collapsed: boolean; + + /** Whether this box is a minor box (can be filtered out by TabDataService). */ + minor: boolean; + + /** CSS classes applied to the box container. */ + style: string; + + /** Whether to insert a clear element after this box. */ + clear: boolean; + + /** Maximum number of columns for this box's content. */ + maxColumn: number; + + /** Whether this box acts as a container for other elements. */ + container: boolean; + + /** Metadata fields that have security restrictions. */ + metadataSecurityFields?: string[]; + + /** Security level required to view this box. */ + security: number; + + /** The box type discriminator (METADATA, RELATION, METRICS, etc.). */ + boxType: string; + + /** Type-specific configuration for the box content. */ + configuration?: RelationBoxConfiguration | MetadataBoxConfiguration | MetricsBoxConfiguration; + +} diff --git a/src/app/core/layout/models/metrics-component.model.ts b/src/app/core/layout/models/metrics-component.model.ts new file mode 100644 index 00000000000..3e3fce2674b --- /dev/null +++ b/src/app/core/layout/models/metrics-component.model.ts @@ -0,0 +1,43 @@ +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { METRICSCOMPONENT } from './metrics-component.resource-type'; + +/** + * Describes a type of metricscomponent + */ +@typedObject +export class MetricsComponent extends CacheableObject { + static type = METRICSCOMPONENT; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of the related DynamicLayoutBox (shortname) + */ + @autoserialize + id: string; + + @autoserialize + metrics: string[]; + + /** + * The {@link HALLink}s for this metricscomponent + */ + @deserialize + _links: { + self: HALLink + }; +} diff --git a/src/app/core/layout/models/metrics-component.resource-type.ts b/src/app/core/layout/models/metrics-component.resource-type.ts new file mode 100644 index 00000000000..ef5c9a51fc2 --- /dev/null +++ b/src/app/core/layout/models/metrics-component.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for boxmetricsconfiguration + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const METRICSCOMPONENT = new ResourceType('boxmetricsconfiguration'); diff --git a/src/app/core/layout/models/section.model.ts b/src/app/core/layout/models/section.model.ts new file mode 100644 index 00000000000..96e0a40a731 --- /dev/null +++ b/src/app/core/layout/models/section.model.ts @@ -0,0 +1,110 @@ +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { SECTION } from './section.resource-type'; + +/** + * Describes a type of Section. + */ +@typedObject +export class Section extends CacheableObject { + static type = SECTION; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Section. + */ + @autoserialize + id: string; + + @autoserialize + componentRows: SectionComponent[][]; + + @autoserialize + nestedSections: Section[]; + + /** + * The {@link HALLink}s for this section + */ + @deserialize + _links: { + self: HALLink, + }; + +} + +export interface SectionComponent { + componentType: string; + style: string; +} + +export interface BrowseSection extends SectionComponent { + browseNames: string[]; + componentType: 'browse'; +} + +export interface TopSection extends SectionComponent { + discoveryConfigurationName: string; + sortField: string; + order: string; + titleKey: string; + componentType: 'top'; + numberOfItems: number; + showThumbnails: boolean; + template: TopSectionTemplateType; +} + +export interface SearchSection extends SectionComponent { + discoveryConfigurationName: string; + componentType: 'search'; + searchType: string; + initialStatements: number; + displayTitle: boolean; +} + +export interface FacetSection extends SectionComponent { + discoveryConfigurationName: string; + componentType: 'facet'; + facetsPerRow: number; +} + +export interface TextRowSection extends SectionComponent { + content: string; + contentType: string; + componentType: 'text-row'; +} + +export interface MultiColumnTopSection extends SectionComponent { + discoveryConfigurationName: string; + sortField: string; + order: string; + titleKey: string; + columnList: TopSectionColumn[]; + componentType: 'multi-column-top'; +} + +export interface TopSectionColumn { + style: string; + metadataField: string; + titleKey: string; +} + +/** + * Represents the type of template to use for the section + */ +export enum TopSectionTemplateType { + DEFAULT = 'default', // default template +} diff --git a/src/app/core/layout/models/section.resource-type.ts b/src/app/core/layout/models/section.resource-type.ts new file mode 100644 index 00000000000..3632eb0391b --- /dev/null +++ b/src/app/core/layout/models/section.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for Section + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const SECTION = new ResourceType('section'); diff --git a/src/app/core/layout/models/tab.model.ts b/src/app/core/layout/models/tab.model.ts new file mode 100644 index 00000000000..885ad74cdfe --- /dev/null +++ b/src/app/core/layout/models/tab.model.ts @@ -0,0 +1,111 @@ +import { + autoserialize, + deserialize, + deserializeAs, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { DynamicLayoutBox } from './box.model'; +import { TAB } from './tab.resource-type'; + +/** + * Model representing a layout tab in the dynamic item page system. + * + * A tab defines a named section of an item's detail page. Tabs contain rows and cells + * forming a grid layout, and may be marked as "leading" (rendered above the main content). + * Tabs are fetched from the REST API via the `/tabs` endpoint. + */ +@typedObject +export class DynamicLayoutTab extends CacheableObject { + static type = TAB; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this DynamicLayoutTab + */ + @autoserialize + id: number; + + @autoserialize + shortname: string; + + @autoserialize + header: string; + + @autoserialize + entityType: string; + + @autoserialize + priority: number; + + @autoserialize + security: number; + + /** + * This property is used from navbar for highlight + * the active tab + */ + isActive?: boolean; + + /** + * This property is used from leading component + */ + @autoserialize + leading?: boolean; + + @autoserialize + rows?: DynamicLayoutRow[]; + /** + * The universally unique identifier of this DynamicLayoutTab + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer(DynamicLayoutTab.type.value), 'id') + uuid: string; + + /** + * The {@link HALLink}s for this DynamicLayoutTab + */ + @deserialize + _links: { + self: HALLink, + }; + + /** + * Contains nested tabs if exist + */ + children?: DynamicLayoutTab[]; +} + + +/** + * A row within a tab's layout grid. Contains cells arranged horizontally. + */ +export interface DynamicLayoutRow { + /** CSS classes applied to the row element (e.g., Bootstrap grid row classes). */ + style: string; + /** Array of cells within this row. */ + cells: DynamicLayoutCell[]; +} + +/** + * A cell within a layout row. Contains one or more boxes. + */ +export interface DynamicLayoutCell { + /** CSS classes applied to the cell element (e.g., Bootstrap column classes). */ + style: string; + /** Array of boxes rendered within this cell. */ + boxes: DynamicLayoutBox[]; +} + diff --git a/src/app/core/layout/models/tab.resource-type.ts b/src/app/core/layout/models/tab.resource-type.ts new file mode 100644 index 00000000000..054c80833c9 --- /dev/null +++ b/src/app/core/layout/models/tab.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for DynamicLayoutTab + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const TAB = new ResourceType('tab'); diff --git a/src/app/core/layout/section-data.service.ts b/src/app/core/layout/section-data.service.ts new file mode 100644 index 00000000000..316f1108ab3 --- /dev/null +++ b/src/app/core/layout/section-data.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { DSONameService } from '../breadcrumbs/dso-name.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { FindAllData } from '../data/base/find-all-data'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { SearchDataImpl } from '../data/base/search-data'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { NotificationsService } from '../notification-system/notifications.service'; +import { FollowLinkConfig } from '../shared/follow-link-config.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Section } from './models/section.model'; + +/** + * A service responsible for fetching data from the REST API on the sections endpoint. + */ +@Injectable({ providedIn: 'root' }) +export class SectionDataService extends IdentifiableDataService
{ + + protected linkPath = 'sections'; + private findAllData: FindAllData
; + private searchData: SearchDataImpl
; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected dsoNameService: DSONameService, + ) { + super('sections', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Find all the configured sections. + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig
[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Find all the configured sections. + */ + findVisibleSections(): Observable>> { + return this.searchData.searchBy('visibleTopBarSections'); + } + +} diff --git a/src/app/core/layout/tab-data.service.spec.ts b/src/app/core/layout/tab-data.service.spec.ts new file mode 100644 index 00000000000..384c9152046 --- /dev/null +++ b/src/app/core/layout/tab-data.service.spec.ts @@ -0,0 +1,694 @@ +import { HttpClient } from '@angular/common/http'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestService } from '../data/request.service'; +import { RequestEntry } from '../data/request-entry.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DynamicLayoutTab } from './models/tab.model'; +import { TAB } from './models/tab.resource-type'; +import { TabDataService } from './tab-data.service'; +import objectContaining = jasmine.objectContaining; +import arrayContaining = jasmine.arrayContaining; +import { NotificationsService } from '../notification-system/notifications.service'; +import { createPaginatedList } from '../testing/utils.test'; +import { createSuccessfulRemoteDataObject } from '../utilities/remote-data.utils'; + +describe('TabDataService', () => { + let scheduler: TestScheduler; + let service: TabDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const tabPersonProfile: DynamicLayoutTab = { + type: TAB, + id: 1, + shortname: 'person-profile', + header: 'person-profile-header', + entityType: 'Person', + priority: 0, + security: 0, + uuid: 'person-profile-1', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/1', + }, + }, + }; + + const tabPersonBiography: DynamicLayoutTab = { + type: TAB, + id: 2, + shortname: 'person-biography', + header: 'person-biography-header', + entityType: 'Person', + priority: 0, + security: 0, + uuid: 'person-biography-2', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/2', + }, + }, + }; + + const tabPersonBibliometrics: DynamicLayoutTab = { + type: TAB, + id: 3, + shortname: 'person-bibliometrics', + header: 'person-bibliometrics-header', + entityType: 'Person', + priority: 0, + security: 0, + uuid: 'person-bibliometrics-3', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/3', + }, + }, + }; + + const tabWithOnlyMinors: DynamicLayoutTab = { + type: TAB, + id: 4, + shortname: 'person-bibliometrics', + header: 'person-bibliometrics-header', + entityType: 'Person', + priority: 0, + security: 0, + rows: [ + { + style: '', + cells: [ + { + style: '', + boxes: [ + { + id: 3418, + shortname: 'heading', + header: null, + entityType: 'Person', + collapsed: false, + minor: true, + style: null, + security: 0, + boxType: 'METADATA', + maxColumn: null, + clear: false, + configuration: { + id: '1', + type: 'boxmetadataconfiguration', + rows: [ + { + style: '', + cells: [ + { + style: '', + fields: [ + { + metadata: 'dc.title', + label: null, + rendering: 'heading', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + ], + }, + ], + }, + ], + }, + metadataSecurityFields: [], + container: false, + }, + ], + }, + ], + }, + { + style: '', + cells: [ + { + style: '', + boxes: [ + { + id: 3419, + shortname: 'namecard', + header: 'Name Card', + entityType: 'Person', + collapsed: false, + minor: true, + style: null, + security: 0, + boxType: 'METADATA', + maxColumn: null, + clear: false, + configuration: { + id: '0', + type: 'boxmetadataconfiguration', + rows: [ + { + style: '', + cells: [ + { + style: 'col-3', + fields: [ + { + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: 'personal pictur', + }, + label: null, + rendering: 'thumbnail', + fieldType: 'BITSTREAM', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + ], + }, + { + style: 'px-2', + fields: [ + { + metadata: 'dc.title', + label: 'Preferred name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.name', + label: 'Official Name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.name.translated', + label: 'Translated Name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.name.variant', + label: 'Alternative Name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.affiliation.name', + label: 'Main Affiliation', + rendering: 'dynamicref', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.workgroup', + label: null, + rendering: 'dynamicref', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'oairecerif.identifier.url', + label: 'Web Site', + rendering: 'link', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.email', + label: 'Email', + rendering: 'dynamicref.email', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.identifier.orcid', + label: 'ORCID', + rendering: 'orcid', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.identifier.scopus-author-id', + label: 'Scopus Author ID', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.identifier.rid', + label: 'Researcher ID', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + ], + }, + ], + }, + ], + }, + metadataSecurityFields: [], + container: false, + }, + ], + }, + ], + }, + ], + uuid: 'person-bibliometrics-4', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/3', + }, + }, + }; + + const tabWithSomeMinors: DynamicLayoutTab = { + type: TAB, + id: 5, + shortname: 'person-bibliometrics', + header: 'person-bibliometrics-header', + entityType: 'Person', + priority: 0, + security: 0, + rows: [ + { + style: '', + cells: [ + { + style: '', + boxes: [ + { + id: 3418, + shortname: 'heading', + header: null, + entityType: 'Person', + collapsed: false, + minor: false, + style: null, + security: 0, + boxType: 'METADATA', + maxColumn: null, + clear: false, + configuration: { + id: '3', + type: 'boxmetadataconfiguration', + rows: [ + { + style: '', + cells: [ + { + style: '', + fields: [ + { + metadata: 'dc.title', + label: null, + rendering: 'heading', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + ], + }, + ], + }, + ], + }, + metadataSecurityFields: [], + container: false, + }, + ], + }, + ], + }, + { + style: '', + cells: [ + { + style: '', + boxes: [ + { + id: 3419, + shortname: 'namecard', + header: 'Name Card', + entityType: 'Person', + collapsed: false, + minor: true, + style: null, + security: 0, + boxType: 'METADATA', + maxColumn: null, + clear: false, + configuration: { + id: '0', + type: 'boxmetadataconfiguration', + rows: [ + { + style: '', + cells: [ + { + style: 'col-3', + fields: [ + { + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: 'personal pictur', + }, + label: null, + rendering: 'thumbnail', + fieldType: 'BITSTREAM', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + ], + }, + { + style: 'px-2', + fields: [ + { + metadata: 'dc.title', + label: 'Preferred name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.name', + label: 'Official Name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.name.translated', + label: 'Translated Name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.name.variant', + label: 'Alternative Name', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.affiliation.name', + label: 'Main Affiliation', + rendering: 'dynamicref', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'crisrp.workgroup', + label: null, + rendering: 'dynamicref', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'oairecerif.identifier.url', + label: 'Web Site', + rendering: 'link', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.email', + label: 'Email', + rendering: 'dynamicref.email', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.identifier.orcid', + label: 'ORCID', + rendering: 'orcid', + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.identifier.scopus-author-id', + label: 'Scopus Author ID', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + { + metadata: 'person.identifier.rid', + label: 'Researcher ID', + rendering: null, + fieldType: 'METADATA', + styleLabel: 'fw-bold col-3', + styleValue: null, + labelAsHeading: false, + valuesInline: false, + }, + ], + }, + ], + }, + ], + }, + metadataSecurityFields: [], + container: false, + }, + ], + }, + ], + }, + ], + uuid: 'person-bibliometrics-5', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/3', + }, + }, + }; + + const endpointURL = `https://rest.api/rest/api/tabs`; + const requestURL = `https://rest.api/rest/api/tabs/${tabPersonProfile.id}`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const itemUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const entityType = 'Person'; + const tabId = '1'; + + const array = [tabPersonProfile, tabPersonBiography, tabPersonBibliometrics, tabWithOnlyMinors, tabWithSomeMinors]; + const paginatedList = createPaginatedList(array); + const tabRD = createSuccessfulRemoteDataObject(tabPersonProfile); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const noMinorsList = + createPaginatedList([tabPersonProfile, tabPersonBiography, tabPersonBibliometrics, tabWithSomeMinors]); + const paginatedListWithoutMinorsRD = createSuccessfulRemoteDataObject(noMinorsList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }), + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: tabRD, + }), + buildList: hot('a|', { + a: paginatedListRD, + }), + }); + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + + service = new TabDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + + spyOn((service as any), 'findById').and.callThrough(); + spyOn((service as any).searchData, 'searchBy').and.callThrough(); + }); + + describe('findById', () => { + it('should proxy the call to dataservice.findById', () => { + scheduler.schedule(() => service.findById(tabId)); + scheduler.flush(); + + expect((service as any).findById).toHaveBeenCalledWith(tabId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findById(tabId); + const expected = cold('a|', { + a: tabRD, + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchByItem', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', itemUUID), + ]; + scheduler.schedule(() => service.findByItem(itemUUID, true)); + scheduler.flush(); + + expect((service as any).searchData.searchBy).toHaveBeenCalledWith((service as any).searchFindByItem, options, true); + }); + + it('should return a RemoteData> for the search', () => { + const result = service.findByItem(itemUUID, true); + const expected = cold('a|', { + a: paginatedListRD, + }); + expect(result).toBeObservable(expected); + }); + + it('should remove tab with minor cells', () => { + const result = service.findByItem(itemUUID, true, true); + result.subscribe(tabs => { + expect(tabs.payload.page).toHaveSize(4); + expect(tabs.payload.page).not.toEqual( + arrayContaining([objectContaining({ id: tabWithOnlyMinors.id })]), + ); + expect(tabs.payload.page).toEqual( + arrayContaining([objectContaining({ id: tabWithSomeMinors.id })]), + ); + }); + }); + + }); + + describe('searchByEntityType', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('type', entityType), + ]; + scheduler.schedule(() => service.findByEntityType(entityType)); + scheduler.flush(); + + expect((service as any).searchData.searchBy).toHaveBeenCalledWith((service as any).searchFindByEntityType, options); + }); + + it('should return a RemoteData> for the search', () => { + const result = service.findByEntityType(entityType); + const expected = cold('a|', { + a: paginatedListRD, + }); + expect(result).toBeObservable(expected); + }); + + }); +}); diff --git a/src/app/core/layout/tab-data.service.ts b/src/app/core/layout/tab-data.service.ts new file mode 100644 index 00000000000..ac3929dcefe --- /dev/null +++ b/src/app/core/layout/tab-data.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@angular/core'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { + hasNoValue, + hasValue, +} from '@dspace/shared/utils/empty.util'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { SearchDataImpl } from '../data/base/search-data'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DynamicLayoutBox } from './models/box.model'; +import { + DynamicLayoutCell, + DynamicLayoutRow, + DynamicLayoutTab, +} from './models/tab.model'; + +/** + * A service responsible for fetching layout tabs from the REST API's `/tabs` endpoint. + * + * Provides methods to find tabs by item UUID or entity type, with support for + * filtering out tabs that contain only minor (secondary) boxes. + */ +@Injectable({ providedIn: 'root' }) +export class TabDataService extends IdentifiableDataService { + protected searchFindByItem = 'findByItem'; + protected searchFindByEntityType = 'findByEntityType'; + private searchData: SearchDataImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService) { + super('tabs', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * It returns the tabs that are available for the specified item. The tabs are sorted by + * priority ascending. This are filtered based on the permission of the current user and + * available data. Empty tabs are filter out. + * @param itemUuid UUID of the Item + * @param useCachedVersionIfAvailable + */ + findByItem( + itemUuid: string, useCachedVersionIfAvailable: boolean, excludeMinors?: boolean, + ): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', itemUuid)]; + + return this.searchData.searchBy(this.searchFindByItem, options, useCachedVersionIfAvailable) + .pipe( + map((data) => { + if (hasValue(data?.payload?.page) && excludeMinors) { + data.payload.page = this.filterTabWithOnlyMinor(data.payload.page); + } + return data; + })); + } + + /** + * Filters out tabs where every box is marked as minor. + * + * @param tabs the full list of tabs to filter + * @returns tabs that contain at least one non-minor box + */ + filterTabWithOnlyMinor(tabs: DynamicLayoutTab[]): DynamicLayoutTab[] { + return tabs.filter(tab => !this.hasTabOnlyMinor(tab)); + } + + /** + * Checks whether all boxes within a tab are minor. + * + * @param tab the tab to inspect + * @returns true if every box in every cell in every row is minor + */ + hasTabOnlyMinor(tab: DynamicLayoutTab): boolean { + if (hasNoValue(tab?.rows)) { + return false; + } + return tab.rows.every(row => this.hasRowOnlyMinor(row)); + } + + hasRowOnlyMinor(row: DynamicLayoutRow): boolean { + if (hasNoValue(row?.cells)) { + return false; + } + return row.cells.every(cell => this.hasCellOnlyMinor(cell)); + } + + hasCellOnlyMinor(cell: DynamicLayoutCell): boolean { + if (hasNoValue(cell?.boxes)) { + return false; + } + return cell.boxes.every(box => this.isMinor(box)); + } + + isMinor(box: DynamicLayoutBox): boolean { + return box.minor === true; + } + + /** + * It returns the tabs that are available for the items of the specified type. + * This endpoint is reserved to system administrators + * @param entityType label of the entity type + */ + findByEntityType(entityType: string): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('type', entityType)]; + return this.searchData.searchBy(this.searchFindByEntityType, options); + } +} diff --git a/src/app/core/metadata/head-tag.service.spec.ts b/src/app/core/metadata/head-tag.service.spec.ts index 34c1a5315de..5a9fdbab25b 100644 --- a/src/app/core/metadata/head-tag.service.spec.ts +++ b/src/app/core/metadata/head-tag.service.spec.ts @@ -27,7 +27,7 @@ import { HardRedirectService } from '../services/hard-redirect.service'; import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; import { Item } from '../shared/item.model'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; import { ItemMock, MockBitstream1, diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index e225441d9e9..acd105a4a9c 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -1,10 +1,7 @@ import { Injectable } from '@angular/core'; import { hasValue } from '@dspace/shared/utils/empty.util'; -import { - MetadataValue, - VIRTUAL_METADATA_PREFIX, -} from '../shared/metadata.models'; +import MetadataValue, { VIRTUAL_METADATA_PREFIX } from '../shared/metadata.models'; /** * Service for working with DSpace object metadata. diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 2b83615c0c1..b5cea601197 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -4,6 +4,8 @@ import { makeEnvironmentProviders, } from '@angular/core'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { DynamicLayoutBox } from '@dspace/core/layout/models/box.model'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; import { SubmissionCustomUrl } from '@dspace/core/submission/models/submission-custom-url.model'; import { Audit } from './audit/model/audit.model'; @@ -232,4 +234,6 @@ export const models = CorrectionType, SupervisionOrder, SubmissionCustomUrl, + DynamicLayoutTab, + DynamicLayoutBox, ]; diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index fa4f4a37115..1da7f2e170d 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -16,10 +16,9 @@ import { excludeFromEquals } from '../utilities/equals.decorators'; import { DSPACE_OBJECT } from './dspace-object.resource-type'; import { GenericConstructor } from './generic-constructor'; import { HALLink } from './hal-link.model'; -import { +import MetadataValue, { MetadataMap, MetadataMapSerializer, - MetadataValue, MetadataValueFilter, MetadatumViewModel, } from './metadata.models'; diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts index a580d7b654b..79dfd15d64f 100644 --- a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts @@ -1,5 +1,5 @@ import { Item } from '../../item.model'; -import { MetadataValue } from '../../metadata.models'; +import MetadataValue from '../../metadata.models'; import { MetadataRepresentationType } from '../metadata-representation.model'; import { ItemMetadataRepresentation } from './item-metadata-representation.model'; diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts index e02adc84b13..72f25e666ce 100644 --- a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts @@ -1,5 +1,5 @@ import { Item } from '../../item.model'; -import { MetadataValue } from '../../metadata.models'; +import MetadataValue from '../../metadata.models'; import { MetadataRepresentation, MetadataRepresentationType, diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts index b81753b1a0b..5d49a0366c8 100644 --- a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts @@ -1,4 +1,4 @@ -import { MetadataValue } from '../../metadata.models'; +import MetadataValue from '../../metadata.models'; import { MetadataRepresentationType } from '../metadata-representation.model'; import { MetadatumRepresentation } from './metadatum-representation.model'; diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts index d06491f09b4..64772e91aeb 100644 --- a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts @@ -1,7 +1,7 @@ import { hasValue } from '@dspace/shared/utils/empty.util'; import { BrowseDefinition } from '../../browse-definition.model'; -import { MetadataValue } from '../../metadata.models'; +import MetadataValue from '../../metadata.models'; import { MetadataRepresentation, MetadataRepresentationType, diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index 2082c645e33..8ec394e573e 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -30,7 +30,7 @@ export class MetadataMap implements MetadataMapInterface { } /** A single metadata value and its properties. */ -export class MetadataValue implements MetadataValueInterface { +class MetadataValue implements MetadataValueInterface { /** The uuid. */ uuid: string = uuidv4(); @@ -62,6 +62,8 @@ export class MetadataValue implements MetadataValueInterface { securityLevel: number; } +export default MetadataValue; + /** Constraints for matching metadata values. */ export interface MetadataValueFilter { /** The language constraint. */ diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index 0333d114cad..6f27da97c99 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -1,9 +1,8 @@ import { isUndefined } from '@dspace/shared/utils/empty.util'; import { v4 as uuidv4 } from 'uuid'; -import { +import MetadataValue, { MetadataMap, - MetadataValue, MetadataValueFilter, MetadatumViewModel, } from './metadata.models'; diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 2f24e0963a7..fa3616acb72 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -11,9 +11,8 @@ import isObject from 'lodash/isObject'; import sortBy from 'lodash/sortBy'; import { validate as uuidValidate } from 'uuid'; -import { +import MetadataValue, { MetadataMapInterface, - MetadataValue, MetadataValueFilter, MetadatumViewModel, } from './metadata.models'; diff --git a/src/app/core/shared/registration.model.ts b/src/app/core/shared/registration.model.ts index 90663042fc9..694a933fcb9 100644 --- a/src/app/core/shared/registration.model.ts +++ b/src/app/core/shared/registration.model.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line max-classes-per-file import { AuthRegistrationType } from '../auth/models/auth.registration-type'; import { typedObject } from '../cache/builders/build-decorators'; -import { MetadataValue } from './metadata.models'; +import MetadataValue from './metadata.models'; import { REGISTRATION } from './registration.resource-type'; import { ResourceType } from './resource-type'; import { UnCacheableObject } from './uncacheable-object.model'; diff --git a/src/app/core/testing/attachments.mock.ts b/src/app/core/testing/attachments.mock.ts new file mode 100644 index 00000000000..e64743dfcd8 --- /dev/null +++ b/src/app/core/testing/attachments.mock.ts @@ -0,0 +1,176 @@ +import { Bitstream } from '../../core/shared/bitstream.model'; +import MetadataValue from '../shared/metadata.models'; + +export const attachmentWithUnspecified = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1', + bundle: undefined, + bundleName: 'ORIGINAL', + format: undefined, + sizeBytes: 130423, + thumbnail: undefined, + type: 'bitstream', + metadata: { + 'dc.description': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'Unspecified description', + } as MetadataValue, + ], + 'dc.title': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'test-unspecified.pdf', + } as MetadataValue, + ], + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'Unspecified', + } as MetadataValue, + ], + }, + _links: { + 'content': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/content', + }, + 'bundle': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/bundle', + }, + 'format': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/format', + }, + 'thumbnail': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/thumbnail', + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7', + }, + }, +}); + +export const attachmentMainArticle = Object.assign(new Bitstream(), { + id: 'bitstream2', + uuid: 'bitstream2', + bundle: undefined, + bundleName: 'ORIGINAL', + format: undefined, + sizeBytes: 130423, + thumbnail: undefined, + type: 'bitstream', + metadata: { + 'dc.description': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'Main article description', + } as MetadataValue, + ], + 'dc.title': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'main.pdf', + } as MetadataValue, + ], + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'Main Article', + } as MetadataValue, + ], + }, + _links: { + 'content': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/content', + }, + 'bundle': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/bundle', + }, + 'format': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/format', + }, + 'thumbnail': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/thumbnail', + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7', + }, + }, +}); + + +export const attachmentRegex = Object.assign(new Bitstream(), { + id: 'bitstream3', + uuid: 'bitstream3', + bundle: undefined, + bundleName: 'ORIGINAL', + format: undefined, + sizeBytes: 130423, + thumbnail: undefined, + type: 'bitstream', + metadata: { + 'dc.description': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'Main article regex description', + } as MetadataValue, + ], + 'dc.title': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'main-regex.pdf', + } as MetadataValue, + ], + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + value: 'Test Article', + } as MetadataValue, + ], + }, + _links: { + 'content': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/content', + }, + 'bundle': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/bundle', + }, + 'format': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/format', + }, + 'thumbnail': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7/thumbnail', + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/c3eaeb39-aa76-4cb7-ab35-b2f2fa8db2a7', + }, + }, +}); + +export const attachmentsMock = [attachmentWithUnspecified, attachmentMainArticle, attachmentRegex]; diff --git a/src/app/core/testing/bitstreams.mock.ts b/src/app/core/testing/bitstreams.mock.ts new file mode 100644 index 00000000000..2cfe8a348b6 --- /dev/null +++ b/src/app/core/testing/bitstreams.mock.ts @@ -0,0 +1,293 @@ +import { Bitstream } from '../../core/shared/bitstream.model'; +import MetadataValue from '../shared/metadata.models'; +import { createSuccessfulRemoteDataObject$ } from '../utilities/remote-data.utils'; + +export const mockThumbnail = Object.assign(new Bitstream(), { + id: 'thumbnail1', + uuid: 'thumbnail1', + sizeBytes: 7798, + metadata: { + 'dc.description': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: 'f7a94983-aaa2-45b4-8fa4-554566734fb5', + value: 'Generated Thumbnail', + } as MetadataValue, + ], + 'dc.title': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '392bb9b3-7de4-41e4-881f-e72b221cfefd', + value: 'Written by FormatFilter org.dspace.app.mediafilter.JPEGFilter on 2022-05-24T15:12:12Z (GMT).', + } as MetadataValue, + ], + 'dc.source': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'young-waiter-2021-08-26-15-47-22-utc-2-pjv2sebbckijc4ix63skyzmskmq00l3p5d9ms2zvqo.jpg', + } as MetadataValue, + ], + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'Test', + } as MetadataValue, + ], + }, + thumbnail: createSuccessfulRemoteDataObject$(null), + _links: { + 'content': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/content', + }, + 'bundle': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/bundle', + }, + 'format': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/format', + }, + 'thumbnail': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/thumbnail', + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b', + }, + }, +}); + +export const bitstreamWithThumbnail = Object.assign(new Bitstream(), { + id: 'bitstream2', + uuid: 'bitstream2', + thumbnail: createSuccessfulRemoteDataObject$(mockThumbnail), +}); + +export const bitstreamWithoutThumbnail = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1', + sizeBytes: 7798, + metadata: { + 'dc.description': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: 'f7a94983-aaa2-45b4-8fa4-554566734fb5', + value: 'Generated Thumbnail', + } as MetadataValue, + ], + 'dc.title': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '392bb9b3-7de4-41e4-881f-e72b221cfefd', + value: 'Written by FormatFilter org.dspace.app.mediafilter.JPEGFilter on 2022-05-24T15:12:12Z (GMT).', + } as MetadataValue, + ], + 'dc.source': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'young-waiter-2021-08-26-15-47-22-utc-2-pjv2sebbckijc4ix63skyzmskmq00l3p5d9ms2zvqo.jpg', + } as MetadataValue, + ], + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'Test', + } as MetadataValue, + ], + }, + thumbnail: createSuccessfulRemoteDataObject$(null), + _links: { + 'content': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/content', + }, + 'bundle': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/bundle', + }, + 'format': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/format', + }, + 'thumbnail': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/thumbnail', + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b', + }, + }, + +}); + +export const mockThumbnailWithType = Object.assign(new Bitstream(), { + id: 'thumbnail1', + uuid: 'thumbnail1', + sizeBytes: 7798, + metadata: { + 'dc.description': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: 'f7a94983-aaa2-45b4-8fa4-554566734fb5', + value: 'Generated Thumbnail', + } as MetadataValue, + ], + 'dc.title': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '392bb9b3-7de4-41e4-881f-e72b221cfefd', + value: 'Written by FormatFilter org.dspace.app.mediafilter.JPEGFilter on 2022-05-24T15:12:12Z (GMT).', + } as MetadataValue, + ], + 'dc.source': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'young-waiter-2021-08-26-15-47-22-utc-2-pjv2sebbckijc4ix63skyzmskmq00l3p5d9ms2zvqo.jpg', + } as MetadataValue, + ], + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'Personal Picture', + } as MetadataValue, + ], + }, + thumbnail: createSuccessfulRemoteDataObject$(null), + _links: { + 'content': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/content', + }, + 'bundle': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/bundle', + }, + 'format': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/format', + }, + 'thumbnail': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b/thumbnail', + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/thumbnail-6df9-40ef-9009-b3c90a4e6d5b', + }, + }, +}); + +export const bitstreamWithThumbnailWithMetadata = Object.assign(new Bitstream(), { + id: 'bitstream2', + uuid: 'bitstream2', + metadata: { + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'Personal Picture', + } as MetadataValue, + ], + }, + thumbnail: createSuccessfulRemoteDataObject$(mockThumbnailWithType), +}); + +export const bitstreamOrignialWithMetadata = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1', + sizeBytes: 7798, + metadata: { + 'dc.description': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: 'f7a94983-aaa2-45b4-8fa4-554566734fb5', + value: 'Generated Thumbnail', + } as MetadataValue, + ], + 'dc.title': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '392bb9b3-7de4-41e4-881f-e72b221cfefd', + value: 'Written by FormatFilter org.dspace.app.mediafilter.JPEGFilter on 2022-05-24T15:12:12Z (GMT).', + } as MetadataValue, + ], + 'dc.source': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'young-waiter-2021-08-26-15-47-22-utc-2-pjv2sebbckijc4ix63skyzmskmq00l3p5d9ms2zvqo.jpg', + } as MetadataValue, + ], + 'dc.type': [ + { + authority: null, + confidence: -1, + language: null, + place: 0, + uuid: '5f1933f7-670c-43f0-beab-f3ea9e753e94', + value: 'Personal Picture', + } as MetadataValue, + ], + }, + thumbnail: createSuccessfulRemoteDataObject$(null), + _links: { + 'content': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/content', + }, + 'bundle': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/bundle', + }, + 'format': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/format', + }, + 'thumbnail': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b/thumbnail', + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/bitstreams/bitstream-6df9-40ef-9009-b3c90a4e6d5b', + }, + }, + +}); diff --git a/src/app/core/testing/box-configurations.mock.ts b/src/app/core/testing/box-configurations.mock.ts new file mode 100644 index 00000000000..b53501e9e3c --- /dev/null +++ b/src/app/core/testing/box-configurations.mock.ts @@ -0,0 +1,249 @@ +import { + MetadataBoxConfiguration, + MetricsBoxConfiguration, + RelationBoxConfiguration, +} from '../../core/layout/models/box.model'; + +export const relationBoxConfigurationMock: RelationBoxConfiguration = { + type: 'boxrelationconfiguration', + 'discovery-configuration': 'RELATION.Person.researchoutputs', +}; + +export const metricsBoxConfigurationMock: MetricsBoxConfiguration = { + type: 'boxmetricsconfiguration', + maxColumns: null, + metrics: ['view', 'embedded-view'], +}; + +export const metadataBoxConfigurationMock: MetadataBoxConfiguration = { + 'id': 'mockBoxConfiguration', + 'type': 'boxmetadataconfiguration', + 'rows': [ + { + 'style': 'row-style', + 'cells': [ + { + 'style': 'col-3', + 'fields': [ + { + 'bitstream': { + 'bundle': 'ORIGINAL', + 'metadataField': 'dc.type', + 'metadataValue': 'personal picture', + }, + 'label': null, + 'rendering': 'thumbnail', + 'fieldType': 'BITSTREAM', + 'style': null, + 'styleLabel': null, + 'styleValue': null, + 'labelAsHeading': false, + 'valuesInline': true, + }, + ], + }, + { + 'style': 'cell-style', + 'fields': [ + { + 'metadata': 'dc.title', + 'label': 'Preferred name', + 'rendering': null, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'crisrp.name', + 'label': 'Official Name', + 'rendering': null, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'crisrp.name.translated', + 'label': 'Translated Name', + 'rendering': null, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'crisrp.name.variant', + 'label': 'Alternative Name', + 'rendering': null, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'oairecerif.identifier.url', + 'label': 'Web Site', + 'rendering': 'link', + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'labelAsHeading': false, + 'valuesInline': true, + }, + ], + }, + ], + }, + { + 'style': 'row-style', + 'cells': [ + { + 'style': 'cell-style', + 'fields': [ + { + 'metadata': 'dc.description.abstract', + 'label': 'Biography', + 'rendering': 'longtext', + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'oairecerif.person.affiliation', + 'label': 'Affiliation', + 'rendering': 'table', + 'fieldType': 'METADATAGROUP', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'metadataGroup': { + 'leading': 'oairecerif.person.affiliation', + 'elements': [ + { + 'metadata': 'oairecerif.affiliation.role', + 'label': 'Role', + 'rendering': 'text', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'oairecerif.person.affiliation', + 'label': 'Organisation', + 'rendering': 'dynamicref', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'oairecerif.affiliation.startDate', + 'label': 'Start', + 'rendering': 'date', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'oairecerif.affiliation.endDate', + 'label': 'End', + 'rendering': 'date', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + ], + }, + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'crisrp.education', + 'label': 'Education', + 'rendering': 'inline', + 'fieldType': 'METADATAGROUP', + 'style': null, + 'styleLabel': 'fw-bold', + 'styleValue': null, + 'metadataGroup': { + 'leading': 'crisrp.education', + 'elements': [ + { + 'metadata': 'crisrp.education.role', + 'label': 'Role', + 'rendering': 'text', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'crisrp.education', + 'label': 'Organisation', + 'rendering': 'dynamicref', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'crisrp.education.start', + 'label': 'Start', + 'rendering': 'date', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + { + 'metadata': 'crisrp.education.end', + 'label': 'End', + 'rendering': 'date', + 'fieldType': 'METADATA', + 'style': 'row', + 'styleLabel': 'col', + 'styleValue': 'col', + 'labelAsHeading': false, + 'valuesInline': true, + }, + ], + }, + 'labelAsHeading': false, + 'valuesInline': true, + }, + ], + }, + ], + }, + ], +}; diff --git a/src/app/core/testing/box.mock.ts b/src/app/core/testing/box.mock.ts new file mode 100644 index 00000000000..f55d4746a90 --- /dev/null +++ b/src/app/core/testing/box.mock.ts @@ -0,0 +1,59 @@ +import { DynamicLayoutBox } from '../../core/layout/models/box.model'; +import { + metadataBoxConfigurationMock, + metricsBoxConfigurationMock, + relationBoxConfigurationMock, +} from './box-configurations.mock'; + +export const boxMetadata: DynamicLayoutBox = { + id: 1, + shortname: 'shortname-box-1', + header: 'header-box-1', + entityType: 'Box', + collapsed: false, + minor: false, + style: 'col-md-4', + clear: false, + maxColumn: null, + container: true, + security: 0, + boxType: 'metadata', + metadataSecurityFields: [], + configuration: metadataBoxConfigurationMock, +}; + +export const boxSearch: DynamicLayoutBox = { + id: 2, + shortname: 'shortname-box-2', + header: 'header-box-2', + entityType: 'Box', + collapsed: false, + minor: false, + style: 'col-md-10', + clear: false, + maxColumn: null, + container: true, + security: 0, + boxType: 'relation', + metadataSecurityFields: [], + configuration: relationBoxConfigurationMock, +}; + +export const boxMetrics: DynamicLayoutBox = { + id: 3, + shortname: 'shortname-box-3', + header: 'header-box-3', + entityType: 'Box', + collapsed: false, + minor: false, + style: 'col-md-2', + clear: false, + maxColumn: null, + container: true, + security: 0, + boxType: 'metrics', + metadataSecurityFields: [], + configuration: metricsBoxConfigurationMock, +}; + +export const boxes = [boxMetadata, boxSearch, boxMetrics]; diff --git a/src/app/core/testing/dso-name.service.mock.ts b/src/app/core/testing/dso-name.service.mock.ts index b12565e6c39..8e0082d920c 100644 --- a/src/app/core/testing/dso-name.service.mock.ts +++ b/src/app/core/testing/dso-name.service.mock.ts @@ -1,5 +1,5 @@ import { DSpaceObject } from '../shared/dspace-object.model'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; export const UNDEFINED_NAME = 'Undefined'; diff --git a/src/app/core/testing/layout-tab.mocks.ts b/src/app/core/testing/layout-tab.mocks.ts new file mode 100644 index 00000000000..c574fb378e2 --- /dev/null +++ b/src/app/core/testing/layout-tab.mocks.ts @@ -0,0 +1,1068 @@ +import { DynamicLayoutTab } from '../../core/layout/models/tab.model'; +import { TAB } from '../../core/layout/models/tab.resource-type'; + +export const tabPersonProfile: DynamicLayoutTab = { + type: TAB, + id: 1, + shortname: 'person-profile', + header: 'person-profile-header', + entityType: 'Person', + priority: 0, + security: 0, + uuid: 'person-profile-1', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/1', + }, + }, +}; + +export const tabPersonBiography: DynamicLayoutTab = { + type: TAB, + id: 2, + shortname: 'person-biography', + header: 'person-biography-header', + entityType: 'Person', + priority: 0, + security: 0, + uuid: 'person-biography-2', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/2', + }, + }, +}; + +export const tabPersonBibliometrics: DynamicLayoutTab = { + type: TAB, + id: 3, + shortname: 'person-bibliometrics', + header: 'person-bibliometrics-header', + entityType: 'Person', + priority: 0, + security: 0, + uuid: 'person-bibliometrics-3', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/3', + }, + }, +}; + +export const tabPersonTest: DynamicLayoutTab = { + type: TAB, + id: 4, + shortname: 'person-test', + header: 'person-test-header', + entityType: 'Person', + priority: 0, + security: 0, + uuid: 'person-test-3', + _links: { + self: { + href: 'https://rest.api/rest/api/tabs/3', + }, + }, +}; + +export const tabs = [tabPersonProfile, tabPersonBiography, tabPersonBibliometrics]; +export const leadingTabs: DynamicLayoutTab[] = [ + Object.assign(new DynamicLayoutTab(), { + 'id': 1, + 'shortname': 'info', + 'header': 'Profile', + 'entityType': 'Person', + 'priority': 1, + 'security': 0, + 'type': 'tab', + 'leading': true, + 'rows': [ + { + 'style': 'test-class', + 'cells': [ + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'primary', + 'header': 'Primary Information', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'container': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 1, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'dc.title', + 'label': 'Name', + 'fieldType': 'metadata', + }, + { + 'metadata': 'person.email', + 'label': 'Email', + 'fieldType': 'metadata', + 'valuesInline': 'true', + }, + ], + }, + ], + }, + }, + { + 'shortname': 'other', + 'header': 'Other Informations', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [ + 'cris.policy.eperson', + ], + 'configuration': { + 'id': 2, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'person.birthDate', + 'label': 'Birth date', + 'fieldType': 'metadata', + 'labelAsHeading': 'true', + }, + ], + }, + ], + }, + }, + ], + }, + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'researchoutputs', + 'header': 'Research outputs', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'RELATION', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 3, + 'discovery-configuration': 'RELATION.Person.researchoutputs', + }, + }, + ], + }, + ], + }, + { + 'cells': [ + { + 'boxes': [ + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + ], + }, + ], + }, + ], + }), +]; +export const loaderTabs: DynamicLayoutTab[] = [Object.assign(new DynamicLayoutTab(), { + 'id': 2, + 'shortname': 'info', + 'header': 'Profile', + 'entityType': 'Person', + 'priority': 1, + 'security': 0, + 'type': 'tab', + 'leading': false, + 'rows': [ + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'primary', + 'header': 'Primary Information', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'container': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 1, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'dc.title', + 'label': 'Name', + 'fieldType': 'metadata', + }, + { + 'metadata': 'person.email', + 'label': 'Email', + 'fieldType': 'metadata', + 'valuesInline': 'true', + }, + ], + }, + ], + }, + }, + { + 'shortname': 'other', + 'header': 'Other Informations', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [ + 'cris.policy.eperson', + ], + 'configuration': { + 'id': 2, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'person.birthDate', + 'label': 'Birth date', + 'fieldType': 'metadata', + 'labelAsHeading': 'true', + }, + ], + }, + ], + }, + }, + ], + }, + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'researchoutputs', + 'header': 'Research outputs', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'RELATION', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 3, + 'discovery-configuration': 'RELATION.Person.researchoutputs', + }, + }, + ], + }, + ], + }, + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-12', + 'boxes': [ + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + ], + }, + ], + }, + ], +}), +Object.assign(new DynamicLayoutTab(), { + 'id': 3, + 'shortname': 'info', + 'header': 'Profile', + 'entityType': 'Person', + 'priority': 1, + 'security': 0, + 'type': 'tab', + 'leading': false, + 'rows': [ + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'primary', + 'header': 'Primary Information', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'container': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 1, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'dc.title', + 'label': 'Name', + 'fieldType': 'metadata', + }, + { + 'metadata': 'person.email', + 'label': 'Email', + 'fieldType': 'metadata', + 'valuesInline': 'true', + }, + ], + }, + ], + }, + }, + { + 'shortname': 'other', + 'header': 'Other Informations', + 'entityType': 'Person', + 'collapsed': false, + 'minor': true, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [ + 'cris.policy.eperson', + ], + 'configuration': { + 'id': 2, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'person.birthDate', + 'label': 'Birth date', + 'fieldType': 'metadata', + 'labelAsHeading': 'true', + }, + ], + }, + ], + }, + }, + ], + }, + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'researchoutputs', + 'header': 'Research outputs', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'RELATION', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 3, + 'discovery-configuration': 'RELATION.Person.researchoutputs', + }, + }, + ], + }, + ], + }, + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-12', + 'boxes': [ + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + ], + }, + ], + }, + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-12', + 'boxes': [ + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': true, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + ], + }, + ], + }, + ], +}), +]; + +export const bothTabs = [...leadingTabs, ...loaderTabs]; + +export const loaderMultilevelTabs: DynamicLayoutTab[] = [ + Object.assign(new DynamicLayoutTab(), { + 'id': 2, + 'shortname': 'info', + 'header': 'Profile', + 'entityType': 'Person', + 'priority': 1, + 'security': 0, + 'type': 'tab', + 'leading': false, + 'rows': [ + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'primary', + 'header': 'Primary Information', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'container': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 1, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'dc.title', + 'label': 'Name', + 'fieldType': 'metadata', + }, + { + 'metadata': 'person.email', + 'label': 'Email', + 'fieldType': 'metadata', + 'valuesInline': 'true', + }, + ], + }, + ], + }, + }, + { + 'shortname': 'other', + 'header': 'Other Informations', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [ + 'cris.policy.eperson', + ], + 'configuration': { + 'id': 2, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'person.birthDate', + 'label': 'Birth date', + 'fieldType': 'metadata', + 'labelAsHeading': 'true', + }, + ], + }, + ], + }, + }, + ], + }, + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'researchoutputs', + 'header': 'Research outputs', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'RELATION', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 3, + 'discovery-configuration': 'RELATION.Person.researchoutputs', + }, + }, + ], + }, + ], + }, + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-12', + 'boxes': [ + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + ], + }, + ], + }, + ], + }), + Object.assign(new DynamicLayoutTab(), { + 'id': 2, + 'shortname': 'Projects::info', + 'header': 'Projects::info', + 'entityType': 'Project', + 'priority': 1, + 'security': 0, + 'type': 'tab', + 'leading': false, + 'rows': [ + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'primary', + 'header': 'Primary Information', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'container': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 1, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'dc.title', + 'label': 'Name', + 'fieldType': 'metadata', + }, + { + 'metadata': 'person.email', + 'label': 'Email', + 'fieldType': 'metadata', + 'valuesInline': 'true', + }, + ], + }, + ], + }, + }, + { + 'shortname': 'other', + 'header': 'Other Informations', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [ + 'cris.policy.eperson', + ], + 'configuration': { + 'id': 2, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'person.birthDate', + 'label': 'Birth date', + 'fieldType': 'metadata', + 'labelAsHeading': 'true', + }, + ], + }, + ], + }, + }, + ], + }, + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'researchoutputs', + 'header': 'Research outputs', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'RELATION', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 3, + 'discovery-configuration': 'RELATION.Person.researchoutputs', + }, + }, + ], + }, + ], + }, + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-12', + 'boxes': [ + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + ], + }, + ], + }, + ], + }), + Object.assign(new DynamicLayoutTab(), { + 'id': 2, + 'shortname': 'Projects::detail', + 'header': 'Projects::detail', + 'entityType': 'Project', + 'priority': 1, + 'security': 0, + 'type': 'tab', + 'leading': false, + 'rows': [ + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'primary', + 'header': 'Primary Information', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'container': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 1, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'dc.title', + 'label': 'Name', + 'fieldType': 'metadata', + }, + { + 'metadata': 'person.email', + 'label': 'Email', + 'fieldType': 'metadata', + 'valuesInline': 'true', + }, + ], + }, + ], + }, + }, + { + 'shortname': 'other', + 'header': 'Other Informations', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METADATA', + 'type': 'box', + 'metadataSecurityFields': [ + 'cris.policy.eperson', + ], + 'configuration': { + 'id': 2, + 'rows': [ + { + 'fields': [ + { + 'metadata': 'person.birthDate', + 'label': 'Birth date', + 'fieldType': 'metadata', + 'labelAsHeading': 'true', + }, + ], + }, + ], + }, + }, + ], + }, + { + 'style': 'col-md-6', + 'boxes': [ + { + 'shortname': 'researchoutputs', + 'header': 'Research outputs', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': 'col-md-6', + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'RELATION', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 3, + 'discovery-configuration': 'RELATION.Person.researchoutputs', + }, + }, + ], + }, + ], + }, + { + 'style': 'col-md-12', + 'cells': [ + { + 'style': 'col-md-12', + 'boxes': [ + { + 'shortname': 'metrics', + 'header': 'Metrics', + 'entityType': 'Person', + 'collapsed': false, + 'minor': false, + 'style': null, + 'clear': true, + 'maxColumn': 2, + 'security': 0, + 'boxType': 'METRICS', + 'type': 'box', + 'metadataSecurityFields': [], + 'configuration': { + 'id': 4, + 'numColumns': 2, + 'metrics': ['views', 'downloads'], + }, + }, + ], + }, + ], + }, + ], + }), +]; +export const tabDetailsTest: DynamicLayoutTab = { + 'id': 395, + 'shortname': 'details', + 'header': 'Informations', + 'entityType': 'OrgUnit', + 'priority': 0, + 'security': 0, + 'type': TAB, + 'uuid': '123123123123', + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/layout/tabs/395', + }, + }, +}; +export const tabPublicationsTest: DynamicLayoutTab = { + 'id': 396, + 'shortname': 'publications', + 'header': 'Publications', + 'entityType': 'OrgUnit', + 'priority': 2, + 'security': 0, + 'type': TAB, + 'uuid': '123123123123', + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/layout/tabs/396', + }, + }, +}; +export const tabRpPublicationsTest: DynamicLayoutTab = { + 'id': 397, + 'shortname': 'rp::publications', + 'header': 'Researchers::Publications', + 'entityType': 'OrgUnit', + 'priority': 4, + 'security': 0, + 'type': TAB, + 'uuid': '123123123123', + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/layout/tabs/397', + }, + }, +}; +export const tabProjectsTest: DynamicLayoutTab = { + 'id': 398, + 'shortname': 'projects', + 'header': 'Projects', + 'entityType': 'OrgUnit', + 'priority': 6, + 'security': 0, + 'type': TAB, + 'uuid': '123123123123', + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/layout/tabs/398', + }, + }, +}; +export const tabRpProjectsTest: DynamicLayoutTab = { + 'id': 399, + 'shortname': 'rp::projects', + 'header': 'Researchers::Projects', + 'entityType': 'OrgUnit', + 'priority': 8, + 'security': 0, + 'type': TAB, + 'uuid': '123123123123', + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/layout/tabs/399', + }, + }, +}; +export const tabPeoplesTest: DynamicLayoutTab = { + 'id': 400, + 'shortname': 'people', + 'header': 'Peoples', + 'entityType': 'OrgUnit', + 'priority': 10, + 'security': 0, + 'type': TAB, + 'uuid': '123123123123', + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/layout/tabs/400', + }, + }, +}; +export const tabFundingsTest: DynamicLayoutTab = { + 'id': 395, + 'shortname': 'outputs::fundings', + 'header': 'Fundings', + 'entityType': 'OrgUnit', + 'priority': 0, + 'security': 0, + 'type': TAB, + 'uuid': '123123123123', + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/layout/tabs/401', + }, + }, +}; +export const tabsWithNestedLevel = [tabDetailsTest, tabPublicationsTest, tabRpPublicationsTest, tabProjectsTest, tabRpProjectsTest, tabPeoplesTest]; diff --git a/src/app/core/testing/relationship-data.service.stub.ts b/src/app/core/testing/relationship-data.service.stub.ts index 9e5eab2cc14..1a02793e0b8 100644 --- a/src/app/core/testing/relationship-data.service.stub.ts +++ b/src/app/core/testing/relationship-data.service.stub.ts @@ -11,7 +11,7 @@ import { DSpaceObject } from '../shared/dspace-object.model'; import { FollowLinkConfig } from '../shared/follow-link-config.model'; import { Item } from '../shared/item.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; -import { MetadataValue } from '../shared/metadata.models'; +import MetadataValue from '../shared/metadata.models'; import { MetadataRepresentation } from '../shared/metadata-representation/metadata-representation.model'; import { NoContent } from '../shared/NoContent.model'; import { createSuccessfulRemoteDataObject$ } from '../utilities/remote-data.utils'; diff --git a/src/app/core/testing/router.stub.ts b/src/app/core/testing/router.stub.ts index 3fe64c481c8..ed2ebb5a82c 100644 --- a/src/app/core/testing/router.stub.ts +++ b/src/app/core/testing/router.stub.ts @@ -16,4 +16,8 @@ export class RouterStub { serializeUrl(commands, navExtras = {}) { return '/testing-url'; } + + public setNavigateReturnValue(value = true) { + this.navigate.and.returnValue(Promise.resolve(value)); + } } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts index affa7944780..40183f06d43 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.spec.ts @@ -7,7 +7,7 @@ import { import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts index 52af4e1432b..d27b65b1b6d 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts @@ -1,5 +1,5 @@ import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { DsoEditMetadataChangeType, diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts index ff60cefef9b..a111279c954 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts @@ -4,10 +4,7 @@ import { MetadataPatchAddOperation } from '@dspace/core/data/object-updates/patc import { MetadataPatchMoveOperation } from '@dspace/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model'; import { MetadataPatchRemoveOperation } from '@dspace/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model'; import { MetadataPatchReplaceOperation } from '@dspace/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model'; -import { - MetadataMap, - MetadataValue, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { MetadataMap } from '@dspace/core/shared/metadata.models'; import { hasNoValue, hasValue, diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts index e06fdc74f30..703daf78748 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.spec.ts @@ -13,7 +13,7 @@ import { Collection } from '@dspace/core/shared/collection.model'; import { ConfidenceType } from '@dspace/core/shared/confidence-type'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub'; 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 cd6d1877e52..efac02c09db 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 @@ -17,10 +17,7 @@ import { NotificationsService } from '@dspace/core/notification-system/notificat import { Collection } from '@dspace/core/shared/collection.model'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { Item } from '@dspace/core/shared/item.model'; -import { - MetadataValue, - VIRTUAL_METADATA_PREFIX, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { VIRTUAL_METADATA_PREFIX } from '@dspace/core/shared/metadata.models'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { DsoEditMetadataFieldServiceStub } from '@dspace/core/testing/dso-edit-metadata-field.service.stub'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts index 2e8387cced8..53e6c201c47 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts @@ -19,7 +19,7 @@ import { NotificationsService } from '@dspace/core/notification-system/notificat import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { Item } from '@dspace/core/shared/item.model'; import { ITEM } from '@dspace/core/shared/item.resource-type'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { MetadataSecurityConfigurationService } from '@dspace/core/submission/metadatasecurityconfig-data.service'; import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; import { TestDataService } from '@dspace/core/testing/test-data-service.mock'; diff --git a/src/app/dynamic-item-page/dynamic-item-page.component.html b/src/app/dynamic-item-page/dynamic-item-page.component.html new file mode 100644 index 00000000000..9a02105b996 --- /dev/null +++ b/src/app/dynamic-item-page/dynamic-item-page.component.html @@ -0,0 +1,11 @@ +@if ((itemRD$ | async)?.isLoading) { + +} +@if (((itemRD$ | async)?.hasSucceeded && !(itemRD$ | async)?.hasNoContent)) { +
+ +
+ @if (!((itemRD$ | async)?.payload?.isWithdrawn) || (isAdmin$|async)) { + + } +} diff --git a/src/app/dynamic-item-page/dynamic-item-page.component.scss b/src/app/dynamic-item-page/dynamic-item-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-item-page/dynamic-item-page.component.spec.ts b/src/app/dynamic-item-page/dynamic-item-page.component.spec.ts new file mode 100644 index 00000000000..56fb8d53b97 --- /dev/null +++ b/src/app/dynamic-item-page/dynamic-item-page.component.spec.ts @@ -0,0 +1,125 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { + createNoContentRemoteDataObject, + createPendingRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { AuthService } from '../core/auth/auth.service'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { ItemDataService } from '../core/data/item-data.service'; +import { Item } from '../core/shared/item.model'; +import { DynamicLayoutComponent } from '../dynamic-layout/dynamic-layout.component'; +import { ThemedItemAlertsComponent } from '../item-page/alerts/themed-item-alerts.component'; +import { createRelationshipsObservable } from '../item-page/simple/item-types/shared/item.component.spec'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; +import { VarDirective } from '../shared/utils/var.directive'; +import { DynamicItemPageComponent } from './dynamic-item-page.component'; + +const mockItem: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: [], + relationships: createRelationshipsObservable(), +}); + +const authService = jasmine.createSpyObj('authService', { + isAuthenticated: of(true), + setRedirectUrl: {}, +}); + +const authorizationService = jasmine.createSpyObj('AuthorizationDataService', { + isAuthorized: of(true), +}); + +describe('DynamicItemPageComponent', () => { + let component: DynamicItemPageComponent; + let fixture: ComponentFixture; + + const mockRoute = Object.assign(new ActivatedRouteStub(), { + data: of({ dso: createSuccessfulRemoteDataObject(mockItem) }), + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, DynamicItemPageComponent, VarDirective], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ActivatedRoute, useValue: mockRoute }, + { provide: ItemDataService, useValue: {} }, + { provide: Router, useValue: {} }, + { provide: AuthService, useValue: authService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(DynamicItemPageComponent, { remove: { imports: [ThemedLoadingComponent, ThemedItemAlertsComponent, DynamicLayoutComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicItemPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('when the item is loading', () => { + beforeEach(() => { + component.itemRD$ = createPendingRemoteDataObject$(undefined); + fixture.detectChanges(); + }); + + it('should display a loading component', () => { + const loading = fixture.debugElement.query(By.css('ds-loading')); + expect(loading.nativeElement).toBeDefined(); + }); + }); + + describe('when the item is loaded', () => { + beforeEach(() => { + component.itemRD$ = createSuccessfulRemoteDataObject$(mockItem); + fixture.detectChanges(); + }); + + it('should display the dynamic layout component', () => { + const layout = fixture.debugElement.query(By.css('ds-dynamic-layout')); + expect(layout.nativeElement).toBeDefined(); + }); + }); + + describe('when the item is no content', () => { + beforeEach(() => { + const itemRD = createNoContentRemoteDataObject(); + itemRD.statusCode = 204; + component.itemRD$ = of(itemRD); + fixture.detectChanges(); + }); + + it('should not display the dynamic layout component', () => { + const layout = fixture.debugElement.query(By.css('ds-dynamic-layout')); + expect(layout).toBeNull(); + }); + }); +}); diff --git a/src/app/dynamic-item-page/dynamic-item-page.component.ts b/src/app/dynamic-item-page/dynamic-item-page.component.ts new file mode 100644 index 00000000000..b4e0adae2c9 --- /dev/null +++ b/src/app/dynamic-item-page/dynamic-item-page.component.ts @@ -0,0 +1,64 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; +import { RemoteData } from '../core/data/remote-data'; +import { Item } from '../core/shared/item.model'; +import { DynamicLayoutComponent } from '../dynamic-layout/dynamic-layout.component'; +import { ThemedItemAlertsComponent } from '../item-page/alerts/themed-item-alerts.component'; +import { fadeInOut } from '../shared/animations/fade'; +import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; + +/** + * Entry point component for the dynamic item page. + * + * Resolves the item from route data and passes it to {@link DynamicLayoutComponent}, + * which handles tab resolution and layout rendering. Also checks admin authorization + * for displaying admin-specific UI elements. + */ +@Component({ + selector: 'ds-dynamic-item-page', + templateUrl: './dynamic-item-page.component.html', + styleUrls: ['./dynamic-item-page.component.scss'], + animations: [fadeInOut], + imports: [ + AsyncPipe, + DynamicLayoutComponent, + ThemedItemAlertsComponent, + ThemedLoadingComponent, + TranslateModule, + ], +}) +export class DynamicItemPageComponent implements OnInit { + + /** + * Whether the current user is an admin or not + */ + isAdmin$: Observable; + + itemRD$: Observable>; + + constructor( + private authorizationService: AuthorizationDataService, + private route: ActivatedRoute, + ) { + + } + + ngOnInit() { + this.itemRD$ = this.route.data.pipe( + map((data) => data.dso as RemoteData), + ); + + this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); + } + +} diff --git a/src/app/dynamic-layout/decorators/dynamic-layout-box.decorator.ts b/src/app/dynamic-layout/decorators/dynamic-layout-box.decorator.ts new file mode 100644 index 00000000000..c34b14bd607 --- /dev/null +++ b/src/app/dynamic-layout/decorators/dynamic-layout-box.decorator.ts @@ -0,0 +1,41 @@ +import { Component } from '@angular/core'; + +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { ItemVersionsComponent } from '../../item-page/versions/item-versions.component'; +import { DynamicLayoutCollectionBoxComponent } from '../dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component'; +import { DynamicLayoutIiifViewerBoxComponent } from '../dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component'; +import { DynamicLayoutMetadataBoxComponent } from '../dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component'; +import { DynamicLayoutRelationBoxComponent } from '../dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component'; +import { LayoutBox } from '../enums/layout-box.enum'; + +/** + * Render options for a dynamic layout box component, specifying its Angular component + * reference and whether it manages its own accordion container. + */ +export interface DynamicLayoutBoxRenderOptions { + /** The component class to instantiate for this box type. */ + componentRef: GenericConstructor; + /** If true, the box provides its own container; the parent won't wrap it in an accordion. */ + hasOwnContainer: boolean; +} + +/** + * Static registry mapping {@link LayoutBox} types to their rendering component and container options. + */ +const layoutBoxesMap = new Map([ + [ LayoutBox.COLLECTIONS, { componentRef: DynamicLayoutCollectionBoxComponent, hasOwnContainer: false } as DynamicLayoutBoxRenderOptions ], + [ LayoutBox.IIIFVIEWER, { componentRef: DynamicLayoutIiifViewerBoxComponent, hasOwnContainer: false } as DynamicLayoutBoxRenderOptions ], + [ LayoutBox.METADATA, { componentRef: DynamicLayoutMetadataBoxComponent, hasOwnContainer: false } as DynamicLayoutBoxRenderOptions ], + [ LayoutBox.RELATION, { componentRef: DynamicLayoutRelationBoxComponent, hasOwnContainer: false } as DynamicLayoutBoxRenderOptions ], + [ LayoutBox.VERSIONING, { componentRef: ItemVersionsComponent, hasOwnContainer: false } as DynamicLayoutBoxRenderOptions ], +]); + +/** + * Resolves the rendering options (component + container flag) for a given box type. + * + * @param boxType the layout box type to look up + * @returns the render options for the box type, or undefined if not registered + */ +export function getDynamicLayoutBox(boxType: LayoutBox): DynamicLayoutBoxRenderOptions { + return layoutBoxesMap.get(boxType); +} diff --git a/src/app/dynamic-layout/decorators/dynamic-layout-page.decorator.ts b/src/app/dynamic-layout/decorators/dynamic-layout-page.decorator.ts new file mode 100644 index 00000000000..dd25b5db5a4 --- /dev/null +++ b/src/app/dynamic-layout/decorators/dynamic-layout-page.decorator.ts @@ -0,0 +1,37 @@ +import { hasNoValue } from '@dspace/shared/utils/empty.util'; + +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { DynamicLayoutHorizontalComponent } from '../dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component'; +import { DynamicLayoutVerticalComponent } from '../dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component'; +import { + DEFAULT_LAYOUT_PAGE, + LayoutPage, +} from '../enums/layout-page.enum'; + +/** + * Static registry mapping {@link LayoutPage} orientation types to their page component. + */ +const layoutPageMap = new Map>([ + [ LayoutPage.HORIZONTAL, DynamicLayoutHorizontalComponent ], + [ LayoutPage.VERTICAL, DynamicLayoutVerticalComponent ], +]); + +layoutPageMap.set(LayoutPage.HORIZONTAL, DynamicLayoutHorizontalComponent); +layoutPageMap.set(LayoutPage.VERTICAL, DynamicLayoutVerticalComponent); + +/** + * Resolves the page layout component for the given orientation. + * Falls back to {@link DEFAULT_LAYOUT_PAGE} if orientation is null or not registered. + * + * @param orientation the layout page orientation (horizontal or vertical) + * @returns the component constructor for the requested orientation + */ +export function getDynamicLayoutPage(orientation: LayoutPage): any { + let componentLayout; + if (hasNoValue(orientation) || hasNoValue(layoutPageMap.get(orientation))) { + componentLayout = layoutPageMap.get(DEFAULT_LAYOUT_PAGE); + } else { + componentLayout = layoutPageMap.get(orientation); + } + return componentLayout; +} diff --git a/src/app/dynamic-layout/directives/dynamic-layout-loader.directive.ts b/src/app/dynamic-layout/directives/dynamic-layout-loader.directive.ts new file mode 100644 index 00000000000..4ecd20db20b --- /dev/null +++ b/src/app/dynamic-layout/directives/dynamic-layout-loader.directive.ts @@ -0,0 +1,15 @@ +import { + Directive, + ViewContainerRef, +} from '@angular/core'; +/** + * Directive hook used to place the dynamic child component + */ +@Directive({ + selector: '[dsDynamicLayoutLoader]', +}) +export class DynamicLayoutLoaderDirective { + + constructor(public viewContainerRef: ViewContainerRef) { } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.html b/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.html new file mode 100644 index 00000000000..49d322ca645 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.html @@ -0,0 +1,11 @@ +
+ @if (showContextMenu) { +
+ +
+ } + + @if (tab) { + + } +
diff --git a/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.scss b/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.spec.ts new file mode 100644 index 00000000000..c4674ec5d9e --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.spec.ts @@ -0,0 +1,29 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; + +import { DynamicLayoutMatrixComponent } from '../dynamic-layout-matrix/dynamic-layout-matrix.component'; +import { DynamicLayoutLeadingComponent } from './dynamic-layout-leading.component'; + +describe('DynamicLayoutLeadingComponent', () => { + let component: DynamicLayoutLeadingComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DynamicLayoutLeadingComponent], + }) + .overrideComponent(DynamicLayoutLeadingComponent, { remove: { imports: [DynamicLayoutMatrixComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutLeadingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.ts b/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.ts new file mode 100644 index 00000000000..8517d97de77 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-leading/dynamic-layout-leading.component.ts @@ -0,0 +1,41 @@ + +import { + Component, + Input, +} from '@angular/core'; + +import { DynamicLayoutTab } from '../../core/layout/models/tab.model'; +import { Item } from '../../core/shared/item.model'; +import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DynamicLayoutMatrixComponent } from '../dynamic-layout-matrix/dynamic-layout-matrix.component'; + +/** + * Component that renders leading tabs (summary/overview panels displayed above the main tabbed content). + * Delegates box rendering to {@link DynamicLayoutMatrixComponent} and optionally shows the edit menu. + */ +@Component({ + selector: 'ds-dynamic-layout-leading', + templateUrl: './dynamic-layout-leading.component.html', + styleUrls: ['./dynamic-layout-leading.component.scss'], + imports: [ + DsoEditMenuComponent, + DynamicLayoutMatrixComponent, + ], +}) +export class DynamicLayoutLeadingComponent { + + /** + * Tabs to render + */ + @Input() tab: DynamicLayoutTab; + + /** + * The related item + */ + @Input() item: Item; + + /** + * A boolean representing if to show context menu or not + */ + @Input() showContextMenu: boolean; +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.html b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.html new file mode 100644 index 00000000000..06335d71035 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.html @@ -0,0 +1,15 @@ +
+ +
+
+ @if (showContextMenu) { +
+ +
+ } + @if (selectedTab$.value) { + + } +
+
+
diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.scss b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.spec.ts new file mode 100644 index 00000000000..b051636a852 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.spec.ts @@ -0,0 +1,49 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { MockActivatedRoute } from '@dspace/core/testing/active-router.mock'; +import { loaderTabs } from '@dspace/core/testing/layout-tab.mocks'; +import { RouterMock } from '@dspace/core/testing/router.mock'; + +import { DynamicLayoutMatrixComponent } from '../../dynamic-layout-matrix/dynamic-layout-matrix.component'; +import { DynamicLayoutHorizontalComponent } from './dynamic-layout-horizontal.component'; +import { DynamicLayoutNavbarComponent } from './dynamic-layout-navbar/dynamic-layout-navbar.component'; + +describe('DynamicLayoutHorizontalComponent', () => { + let component: DynamicLayoutHorizontalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DynamicLayoutHorizontalComponent], + providers: [ + { provide: Router, useValue: new RouterMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + ], + }) + .overrideComponent(DynamicLayoutHorizontalComponent, { remove: { imports: [DynamicLayoutNavbarComponent, DynamicLayoutMatrixComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutHorizontalComponent); + component = fixture.componentInstance; + component.leadingTabs = []; + component.tabs = loaderTabs; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show navbar', () => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-navbar'))).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.ts new file mode 100644 index 00000000000..1b40080c1f2 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-horizontal.component.ts @@ -0,0 +1,58 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { BehaviorSubject } from 'rxjs'; + +import { DsoEditMenuComponent } from '../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DynamicLayoutMatrixComponent } from '../../dynamic-layout-matrix/dynamic-layout-matrix.component'; +import { DynamicLayoutNavbarComponent } from './dynamic-layout-navbar/dynamic-layout-navbar.component'; + +/** + * Horizontal layout page component for the dynamic item page. + * Renders tabs as a horizontal top navbar, with the selected tab's content + * displayed below via {@link DynamicLayoutMatrixComponent}. + */ +@Component({ + selector: 'ds-dynamic-layout-horizontal', + templateUrl: './dynamic-layout-horizontal.component.html', + styleUrls: ['./dynamic-layout-horizontal.component.scss'], + imports: [ + AsyncPipe, + DsoEditMenuComponent, + DynamicLayoutMatrixComponent, + DynamicLayoutNavbarComponent, + ], +}) +export class DynamicLayoutHorizontalComponent { + + /** + * DSpace Item to render + */ + @Input() item: Item; + + /** + * Tabs to render + */ + @Input() tabs: DynamicLayoutTab[]; + + /** + * A boolean representing if to show context menu or not + */ + @Input() showContextMenu: boolean; + + /** + * leadingTabs to understand if to show navbar + */ + @Input() leadingTabs: DynamicLayoutTab[]; + + + selectedTab$: BehaviorSubject = new BehaviorSubject(null); + + selectedTabChanged(tab: DynamicLayoutTab) { + this.selectedTab$.next(tab); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.html b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.html new file mode 100644 index 00000000000..66bb80ff82f --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.html @@ -0,0 +1,26 @@ +@if ((isXsOrSm$ | async) && showNav) { + +} + +@if (showNav) { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.scss b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.scss new file mode 100644 index 00000000000..50eb7875e3a --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.scss @@ -0,0 +1,70 @@ +.dynamic-layout-navbar { + z-index: 1025; +} +.navbar-nav{ + justify-content: flex-start; + height: 60px; + align-items: center; +} + +.nav-item{ + height: 100%; + align-items: center; + display: flex; + justify-content: center; +} + +nav.navbar { + align-items: baseline; + background-color: var(--ds-dynamic-layout-navbar-background-color); +} + +/** Mobile menu styling **/ +@media screen and (max-width: map-get($grid-breakpoints, md)) { + .navbar { + width: 100vw; + background-color: var(--bs-white); + overflow: hidden; + height: 0; + &.open { + height: auto !important; + } + &.ng-animating { + height: auto !important; + } + } + .navbar-nav{ + height: auto; + } + .sidebar-section{ + width: 100%; + } + :host{ + width: 100%; + } + + nav.navbar { + border-bottom: 1px var(--bs-gray-400) solid; + align-items: baseline; + } + + .navbar-toggler .navbar-toggler-icon { + background-image: none !important; + line-height: 1.5; + color: var(--bs-link-color); + } +} + +@media screen and (min-width: map-get($grid-breakpoints, md)) { + .reset-padding-md { + margin-left: calc(var(--bs-spacer) / -2); + margin-right: calc(var(--bs-spacer) / -2); + } +} + +/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */ +.navbar-expand-md.navbar-container { + @media screen and (max-width: map-get($grid-breakpoints, md)) { + padding: 0; + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.spec.ts new file mode 100644 index 00000000000..d9a3318b826 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.spec.ts @@ -0,0 +1,149 @@ +import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; +import { HostWindowServiceStub } from '@dspace/core/testing/host-window-service.stub'; +import { loaderMultilevelTabs } from '@dspace/core/testing/layout-tab.mocks'; +import { RouterStub } from '@dspace/core/testing/router.stub'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + of, +} from 'rxjs'; + +import { HostWindowService } from '../../../../shared/host-window.service'; +import { DynamicLayoutSidebarItemComponent } from '../../shared/sidebar-item/dynamic-layout-sidebar-item.component'; +import { DynamicLayoutNavbarComponent } from './dynamic-layout-navbar.component'; + + +describe('DynamicLayoutNavbarComponent', () => { + let component: DynamicLayoutNavbarComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let router: RouterStub; + + const windowServiceStub = new HostWindowServiceStub(1000); + + const activatedRouteStub = new ActivatedRouteStub(); + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test', + }, + ], + 'dspace.entity.type': [ + { + language: null, + value: 'Person', + }, + ], + }, + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + DynamicLayoutNavbarComponent, + ], + providers: [ + { provide: HostWindowService, useValue: windowServiceStub }, + { provide: Router, useClass: RouterStub }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + ], + }) + .overrideComponent(DynamicLayoutNavbarComponent, { remove: { imports: [DynamicLayoutSidebarItemComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutNavbarComponent); + router = TestBed.inject(Router) as unknown as RouterStub; + router.setNavigateReturnValue(true); + component = fixture.componentInstance; + component.item = mockItem; + component.tabs = []; + component.showNav = true; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + + describe('when there are tabs', () => { + + beforeEach(() => { + component.tabs = loaderMultilevelTabs; + component.item = mockItem; + component.activeTab$ = new BehaviorSubject(loaderMultilevelTabs[0]); + component.isXsOrSm$ = of(true); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('navbar should be collapsed', () => { + expect(de.query(By.css('.dynamic-layout-navbar.collapsed'))).toBeTruthy(); + expect(de.query(By.css('.dynamic-layout-navbar.expanded'))).toBeNull(); + }); + + describe('when small screens < 576', () => { + + beforeEach(() => { + windowServiceStub.setWidth(400); + component.ngOnInit(); + fixture.detectChanges(); + }); + + xit('should show navbar items', () => { + expect(de.query(By.css('.dynamic-layout-navbar')).nativeElement.clientHeight).toEqual(0); + }); + + it('should not show navbar-toggler', () => { + expect(de.query(By.css('.navbar-toggler'))).toBeTruthy(); + }); + + it('when click navbar-toggler should be expanded', () => { + + const navbarToggler = de.query(By.css('.navbar-toggler')); + navbarToggler.nativeElement.click(); + fixture.detectChanges(); + + expect(de.query(By.css('.dynamic-layout-navbar.expanded'))).toBeTruthy(); + expect(de.query(By.css('.dynamic-layout-navbar.collapsed'))).toBeNull(); + }); + + }); + }); + + + + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.ts new file mode 100644 index 00000000000..479d7552163 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component.ts @@ -0,0 +1,72 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + Component, + EventEmitter, + inject, + Input, + OnInit, + Output, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { DynamicLayoutTab } from '../../../../core/layout/models/tab.model'; +import { Item } from '../../../../core/shared/item.model'; +import { slideMobileNav } from '../../../../shared/animations/slide'; +import { HostWindowService } from '../../../../shared/host-window.service'; +import { DynamicLayoutTabsComponent } from '../../shared/dynamic-layout-tabs/dynamic-layout-tabs.component'; +import { DynamicLayoutSidebarItemComponent } from '../../shared/sidebar-item/dynamic-layout-sidebar-item.component'; + +@Component({ + selector: 'ds-dynamic-layout-navbar', + templateUrl: './dynamic-layout-navbar.component.html', + styleUrls: ['./dynamic-layout-navbar.component.scss'], + animations: [slideMobileNav], + imports: [ + AsyncPipe, + DynamicLayoutSidebarItemComponent, + NgClass, + TranslateModule, + ], +}) +export class DynamicLayoutNavbarComponent extends DynamicLayoutTabsComponent implements OnInit { + + /** + * Tabs to render + */ + @Input() tabs: DynamicLayoutTab[]; + + /** + * Item that is being viewed + */ + @Input() item: Item; + + @Input() showNav: boolean; + + menuCollapsed = true; + + /** + * Item that is being viewed + */ + @Output() selectedTabChange = new EventEmitter(); + + windowService = inject(HostWindowService); + + isXsOrSm$: Observable; + + ngOnInit(): void { + this.init(); + this.isXsOrSm$ = this.windowService.isXsOrSm(); + } + + emitSelected(selectedTab) { + this.selectedTabChange.emit(selectedTab); + } + + toggleNavbar() { + this.menuCollapsed = !this.menuCollapsed; + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.html b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.html new file mode 100644 index 00000000000..5d90ae6597e --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.html @@ -0,0 +1 @@ + diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.scss b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.spec.ts new file mode 100644 index 00000000000..2517ab4b228 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.spec.ts @@ -0,0 +1,133 @@ +import { CommonModule } from '@angular/common'; +import { + EventEmitter, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + ActivatedRoute, + provideRouter, +} from '@angular/router'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { HostWindowServiceStub } from '@dspace/core/testing/host-window-service.stub'; +import { loaderTabs } from '@dspace/core/testing/layout-tab.mocks'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { Item } from '../../core/shared/item.model'; +import { HostWindowService } from '../../shared/host-window.service'; +import { DynamicLayoutLoaderDirective } from '../directives/dynamic-layout-loader.directive'; +import { DynamicLayoutHorizontalComponent } from './dynamic-layout-horizontal/dynamic-layout-horizontal.component'; +import { DynamicLayoutLoaderComponent } from './dynamic-layout-loader.component'; +import { DynamicLayoutVerticalComponent } from './dynamic-layout-vertical/dynamic-layout-vertical.component'; + +describe('DynamicLayoutLoaderComponent', () => { + let component: DynamicLayoutLoaderComponent; + let fixture: ComponentFixture; + const windowServiceStub = new HostWindowServiceStub(1200); + + const translateServiceStub = { + get: () => of('translated-text'), + onLangChange: new EventEmitter(), + onTranslationChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter(), + }; + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test', + }, + ], + 'dspace.entity.type': [ + { + language: null, + value: 'Person', + }, + ], + }, + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: TranslateLoaderMock }, + }), + DynamicLayoutLoaderComponent, + DynamicLayoutLoaderDirective, + DynamicLayoutVerticalComponent, + DynamicLayoutHorizontalComponent, + ], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { snapshot: { + paramMap: { + get: (key: string) => { + const params = { + tab: 'test-tab', + }; + return params[key]; + }, + }, + } } }, + { provide: HostWindowService, useValue: windowServiceStub }, + { provide: APP_CONFIG, useValue: { + layout: { + itemPage: { + default: { + orientation: 'horizontal', + }, + }, + }, + }, + }, + + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutLoaderComponent); + component = fixture.componentInstance; + component.item = mockItem; + component.leadingTabs = []; + component.tabs = loaderTabs; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('if config is vertical should show vertical component', () => { + component.layoutConfiguration = { orientation: 'vertical' }; + component.initComponent(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.vertical-layout'))).toBeTruthy(); + }); + + it('if config is horizontal should show horizontal component', () => { + component.layoutConfiguration = { orientation: 'horizontal' }; + component.initComponent(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.horizontal-layout'))).toBeTruthy(); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.ts new file mode 100644 index 00000000000..50e3a657330 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-loader.component.ts @@ -0,0 +1,129 @@ +import { + Component, + ComponentRef, + Inject, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import { + APP_CONFIG, + AppConfig, +} from '@dspace/config/app-config.interface'; +import { DynamicLayoutTypeConfig } from '@dspace/config/layout-config.interfaces'; + +import { DynamicLayoutTab } from '../../core/layout/models/tab.model'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { Item } from '../../core/shared/item.model'; +import { getDynamicLayoutPage } from '../decorators/dynamic-layout-page.decorator'; +import { DynamicLayoutLoaderDirective } from '../directives/dynamic-layout-loader.directive'; +import { LayoutPage } from '../enums/layout-page.enum'; + +/** + * Loader component that dynamically instantiates the correct layout page component + * (horizontal or vertical) based on the entity type's configuration in the app config. + * + * Uses the {@link DynamicLayoutLoaderDirective} as a ViewChild anchor to place the + * dynamically created component, passing the item, tabs, and context menu settings. + */ +@Component({ + selector: 'ds-dynamic-layout-loader', + templateUrl: './dynamic-layout-loader.component.html', + styleUrls: ['./dynamic-layout-loader.component.scss'], + imports: [ + DynamicLayoutLoaderDirective, + ], +}) +export class DynamicLayoutLoaderComponent implements OnInit, OnDestroy { + + /** + * DSpace Item to render + */ + @Input() item: Item; + + /** + * Tabs to render + */ + @Input() tabs: DynamicLayoutTab[]; + + /** + * A boolean representing if to show context menu or not + */ + @Input() showContextMenu: boolean; + + /** + * Layout type configuration (orientation) resolved from the app config for the item's entity type. + */ + layoutConfiguration: DynamicLayoutTypeConfig; + + + @Input() leadingTabs: DynamicLayoutTab[]; + + /** + * Directive hook used to place the dynamic child component + */ + @ViewChild(DynamicLayoutLoaderDirective, { static: true }) dynamicLayoutLoader: DynamicLayoutLoaderDirective; + + /** + * componentRef reference of the component that will be created + */ + componentRef: ComponentRef; + + constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { + } + + ngOnInit(): void { + this.getConfiguration(); + this.initComponent(); + } + + /** + * Get tabs for the specific item and the configuration for the item + */ + getConfiguration(): void { + const itemType = this.item ?.firstMetadataValue('dspace.entity.type'); + const def = 'default'; + + if (!!this.appConfig.layout.itemPage && !!this.appConfig.layout.itemPage[itemType]) { + this.layoutConfiguration = this.appConfig.layout.itemPage[itemType]; + } else { + this.layoutConfiguration = this.appConfig.layout.itemPage[def]; + } + } + + /** + * Initialize the component depending on the layout configuration + */ + initComponent(): void { + const component: GenericConstructor = this.getComponent(); + const viewContainerRef = this.dynamicLayoutLoader.viewContainerRef; + viewContainerRef.clear(); + + this.componentRef = viewContainerRef.createComponent(component); + (this.componentRef.instance as any).item = this.item; + (this.componentRef.instance as any).tabs = this.tabs; + (this.componentRef.instance as any).showContextMenu = this.showContextMenu; + (this.componentRef.instance as any).leadingTabs = this.leadingTabs; + this.componentRef.changeDetectorRef.detectChanges(); + } + + /** + * Fetch the component depending on the item + * @returns {GenericConstructor} + */ + private getComponent(): GenericConstructor { + return getDynamicLayoutPage(this.layoutConfiguration.orientation as LayoutPage); + } + + /** + * Destroy componentRef when this component is destroyed + */ + ngOnDestroy(): void { + if (this.componentRef) { + this.componentRef.destroy(); + } + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.html b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.html new file mode 100644 index 00000000000..56660695423 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.html @@ -0,0 +1,36 @@ +
+ @if ((hasSidebar$ | async) && (isSideBarHidden$ | async) !== true && showNav) { + + } +
+ + @if ((hasSidebar$ | async)) { +
+ +
+ } +
+ +
+ +
+ +
diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.scss b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.scss new file mode 100644 index 00000000000..ae271dc4771 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.scss @@ -0,0 +1,116 @@ +.wrapper { + margin-bottom: -24px; +} + +.navbar { + padding: 15px 10px; + background: #fff; + border: none; + border-radius: 0; + margin-bottom: 40px; + box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1); +} + +.navbar-btn { + box-shadow: none; + outline: none !important; + border: none; +} + +#sidebar { + min-height: calc(100vh - 500px); + width: var(--ds-dynamic-layout-sidebar-width); + color: var(--ds-dynamic-layout-sidebar-link-color-active); + background: var(--ds-dynamic-layout-sidebar-background-color); + transition: all 0.3s; + position: relative; + &.active{ + /*margin-left: var(--ds-dynamic-layout-sidebar-width);*/ + .sidebar-menu{ + margin-right: -50px; + } + } + ul.components{ + padding: 0; + } + ul{ + li{ + font-size: 16px; + >ul{ + margin-left: 10px; + li{ + font-size: 14px; + } + } + a{ + padding: 10px 0; + display: block; + color: var(--ds-dynamic-layout-sidebar-link-color); + border-bottom: 1px solid rgba(0,0,0,.1); + &:hover{ + color: var(--ds-dynamic-layout-sidebar-link-color); + } + } + &.active{ + > a{ + background: transparent; + color: var(--ds-dynamic-layout-sidebar-link-color-active); + } + } + } + } +} + + +.sidebar-menu{ + display: inline-block; + position: absolute; + top: 20px; + left: calc(0px - var(--ds-dynamic-layout-sidebar-entry-width)/2); + // margin-right: -50px; + @include transition(.3s); + .btn{ + width: var(--ds-dynamic-layout-sidebar-entry-width); + height: var(--ds-dynamic-layout-sidebar-entry-height); + border-radius: 50%; + &.btn-primary{ + background: var(--ds-dynamic-layout-sidebar-background-color); + border-color: var(--ds-dynamic-layout-sidebar-border-color); + &:hover, &:focus{ + background: var(--ds-dynamic-layout-sidebar-hover-focus-background-color) !important; + border-color: var(--ds-dynamic-layout-sidebar-hover-focus-border-color) !important; + } + } + } +} + +.menu-toggle { + position: absolute; + top: 0; + left: -30px; + z-index: 1000; + opacity: 0.5; +} + +.menu-toggle:hover { + opacity: 1; + outline: none !important; + box-shadow: none !important; +} + +a[data-toggle="collapse"] { + position: relative; +} + +.dropdown-toggle::after { + display: block; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); +} + +.btn-arrow{ + background: #207698; + color: #fff; +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.spec.ts new file mode 100644 index 00000000000..ca4c16c3909 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.spec.ts @@ -0,0 +1,122 @@ +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { + tabPersonBibliometrics, + tabPersonBiography, + tabPersonProfile, +} from '@dspace/core/testing/layout-tab.mocks'; +import { BehaviorSubject } from 'rxjs'; + +import { DynamicLayoutSidebarItemComponent } from '../../shared/sidebar-item/dynamic-layout-sidebar-item.component'; +import { DynamicLayoutSidebarComponent } from './dynamic-layout-sidebar.component'; + +describe('DynamicLayoutSidebarComponent', () => { + let component: DynamicLayoutSidebarComponent; + let fixture: ComponentFixture; + let de: DebugElement; + const tabs = [tabPersonProfile, tabPersonBiography, tabPersonBibliometrics]; + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test', + }, + ], + 'dspace.entity.type': [ + { + language: null, + value: 'Person', + }, + ], + }, + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + DynamicLayoutSidebarComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(DynamicLayoutSidebarComponent, { remove: { imports: [DynamicLayoutSidebarItemComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutSidebarComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + }); + + describe('when no tabs', () => { + + beforeEach(() => { + component.tabs = []; + component.item = mockItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not show sidebar', () => { + expect(de.query(By.css('#sidebar'))).toBeNull(); + }); + + it('should not show sidebar show/hide button', () => { + expect(de.query(By.css('.menu-toggle'))).toBeNull(); + }); + + }); + + describe('when there are tabs', () => { + beforeEach(() => { + component.item = mockItem; + component.tabs = tabs; + component.showNav = true; + component.activeTab$ = new BehaviorSubject(tabs[0]); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show sidebar', () => { + expect(de.query(By.css('#sidebar'))).toBeTruthy(); + }); + + it('should show sidebar show/hide button', () => { + expect(de.query(By.css('.menu-toggle'))).toBeTruthy(); + }); + + it('should show 3 sidebar items', () => { + expect(de.queryAll(By.css('ds-dynamic-layout-sidebar-item')).length).toEqual(3); + }); + + it('when first click show/hide button should hide sidebar', () => { + const btn = de.query(By.css('.menu-toggle')); + btn.nativeElement.click(); + fixture.detectChanges(); + expect(de.query(By.css('#sidebar'))).toBeNull(); + }); + + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.ts new file mode 100644 index 00000000000..e0dce19c2b8 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-sidebar/dynamic-layout-sidebar.component.ts @@ -0,0 +1,116 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { DynamicLayoutTabsComponent } from '../../shared/dynamic-layout-tabs/dynamic-layout-tabs.component'; +import { DynamicLayoutSidebarItemComponent } from '../../shared/sidebar-item/dynamic-layout-sidebar-item.component'; + +@Component({ + selector: 'ds-dynamic-layout-sidebar', + templateUrl: './dynamic-layout-sidebar.component.html', + styleUrls: ['./dynamic-layout-sidebar.component.scss'], + imports: [ + AsyncPipe, + DynamicLayoutSidebarItemComponent, + NgClass, + ], +}) +export class DynamicLayoutSidebarComponent extends DynamicLayoutTabsComponent implements OnInit { + + /** + * Tabs to render + */ + @Input() tabs: DynamicLayoutTab[]; + + /** + * Item that is being viewed + */ + @Input() item: Item; + + /** + * If the navigation bar should be visible or not + */ + @Input() showNav: boolean; + + /** + * The new tab selection event + */ + @Output() selectedTabChange = new EventEmitter(); + + /** + * A boolean representing if to render or not the sidebar menu + */ + hasSidebar$: BehaviorSubject = new BehaviorSubject(false); + + /** + * This parameter define the status of sidebar (hide/show) + */ + private sidebarStatus$: BehaviorSubject = new BehaviorSubject(false); + + isSideBarHidden$: Observable; + + ngOnInit(): void { + this.init(); + // Check if to show sidebar + this.hasSidebar$.next(!!this.tabs && this.tabs.length > 1); + + if (!this.hasSidebar$.value && !!this.tabs && this.tabs.length === 1) { + this.selectTabEv(this.tabs[0]); + } + + // Init the sidebar status + this.sidebarStatus$.next(this.hasSidebar$.value); + this.isSideBarHidden$ = this.isSideBarHidden(); + } + + /** + * Emit a new selectedTabChange Event + * @param tab + */ + selectTabEv(tab: DynamicLayoutTab) { + this.setActiveTab(tab); + } + + emitSelected(selectedTab) { + this.selectedTabChange.emit(selectedTab); + } + + /** + * Check if sidebar is present + */ + hasSidebar(): Observable { + return this.hasSidebar$.asObservable(); + } + + /** + * Return the sidebar status + */ + isSideBarHidden(): Observable { + return this.sidebarStatus$.asObservable().pipe( + map((status: boolean) => !status), + ); + } + + + /** + * It is used for hide/show the left sidebar + */ + toggleSidebar(): void { + this.sidebarStatus$.next(!this.sidebarStatus$.value); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.html b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.html new file mode 100644 index 00000000000..d46d3aec81c --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.html @@ -0,0 +1,24 @@ +
+ @if ((isXsOrSm$ | async) !== true) { +
+ + @if (showContextMenu) { +
+ +
+ } + +
+
+ } + + @if ((isXsOrSm$ | async)) { +
+ +
+ +
+
+ } +
diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.scss b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.spec.ts new file mode 100644 index 00000000000..c3406c1cc19 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.spec.ts @@ -0,0 +1,52 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { MockActivatedRoute } from '@dspace/core/testing/active-router.mock'; +import { HostWindowServiceStub } from '@dspace/core/testing/host-window-service.stub'; +import { loaderTabs } from '@dspace/core/testing/layout-tab.mocks'; +import { RouterMock } from '@dspace/core/testing/router.mock'; + +import { HostWindowService } from '../../../shared/host-window.service'; +import { DynamicLayoutMatrixComponent } from '../../dynamic-layout-matrix/dynamic-layout-matrix.component'; +import { DynamicLayoutNavbarComponent } from '../dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component'; +import { DynamicLayoutSidebarComponent } from './dynamic-layout-sidebar/dynamic-layout-sidebar.component'; +import { DynamicLayoutVerticalComponent } from './dynamic-layout-vertical.component'; + +describe('DynamicLayoutVerticalComponent', () => { + let component: DynamicLayoutVerticalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DynamicLayoutVerticalComponent], + providers: [ + { provide: HostWindowService, useValue: new HostWindowServiceStub(1200) }, + { provide: Router, useValue: new RouterMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + ], + }) + .overrideComponent(DynamicLayoutVerticalComponent, { remove: { imports: [DynamicLayoutSidebarComponent, DynamicLayoutMatrixComponent, DynamicLayoutNavbarComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutVerticalComponent); + component = fixture.componentInstance; + component.tabs = loaderTabs; + component.leadingTabs = []; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show sidebar', () => { + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-sidebar'))).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.ts b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.ts new file mode 100644 index 00000000000..117ddfa747c --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/dynamic-layout-vertical/dynamic-layout-vertical.component.ts @@ -0,0 +1,69 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; + +import { DsoEditMenuComponent } from '../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { HostWindowService } from '../../../shared/host-window.service'; +import { DynamicLayoutMatrixComponent } from '../../dynamic-layout-matrix/dynamic-layout-matrix.component'; +import { DynamicLayoutNavbarComponent } from '../dynamic-layout-horizontal/dynamic-layout-navbar/dynamic-layout-navbar.component'; +import { DynamicLayoutSidebarComponent } from './dynamic-layout-sidebar/dynamic-layout-sidebar.component'; + +/** + * Vertical layout page component for the dynamic item page. + * Renders tabs as a vertical sidebar on larger screens (falling back to a horizontal + * navbar on small screens), with the selected tab's content displayed alongside. + */ +@Component({ + selector: 'ds-dynamic-layout-vertical', + templateUrl: './dynamic-layout-vertical.component.html', + styleUrls: ['./dynamic-layout-vertical.component.scss'], + imports: [ + AsyncPipe, + DsoEditMenuComponent, + DynamicLayoutMatrixComponent, + DynamicLayoutNavbarComponent, + DynamicLayoutSidebarComponent, + ], +}) +export class DynamicLayoutVerticalComponent { + + /** + * DSpace Item to render + */ + @Input() item: Item; + + /** + * Tabs to render + */ + @Input() tabs: DynamicLayoutTab[]; + + /** + * A boolean representing if to show context menu or not + */ + @Input() showContextMenu: boolean; + + /** + * leadingTabs to understand if to show navbar + */ + @Input() leadingTabs: DynamicLayoutTab[]; + + + selectedTab$: BehaviorSubject = new BehaviorSubject(null); + isXsOrSm$: Observable; + + constructor(public windowService: HostWindowService) { + this.isXsOrSm$ = this.windowService.isXsOrSm(); + } + + selectedTabChanged(tab: DynamicLayoutTab) { + this.selectedTab$.next(tab); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/shared/dynamic-layout-tabs/dynamic-layout-tabs.component.ts b/src/app/dynamic-layout/dynamic-layout-loader/shared/dynamic-layout-tabs/dynamic-layout-tabs.component.ts new file mode 100644 index 00000000000..2cae98e0c81 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/shared/dynamic-layout-tabs/dynamic-layout-tabs.component.ts @@ -0,0 +1,136 @@ +import { Location } from '@angular/common'; +import { + Component, + EventEmitter, + inject, + Input, + Output, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; +import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; +import { Item } from '@dspace/core/shared/item.model'; +import { isNotNull } from '@dspace/shared/utils/empty.util'; +import { BehaviorSubject } from 'rxjs'; + +/** + * This component render the sidebar of the tabs layout + */ +@Component({ + selector: 'ds-dynamic-layout-tabs-sidebar', + template: '', +}) +export abstract class DynamicLayoutTabsComponent { + + /** + * The item object related to the page + */ + @Input() item: Item; + /** + * Representing if sidebar should be displayed or not + */ + @Input() sidebarActive: boolean; + /** + * hide/show the sidebar + */ + @Input() sidebarStatus: boolean; + /** + * tabs list + */ + tabs: DynamicLayoutTab[] = []; + + + /** + * A boolean representing if a request operation is pending + * @type {BehaviorSubject} + */ + public activeTab$ = new BehaviorSubject(undefined); + + + /** + * used for notify tab selection + */ + @Output() selectedTab = new EventEmitter(); + + /** + * The item base url + */ + itemBaseUrl: string; + + location = inject(Location); + route = inject(ActivatedRoute); + router = inject(Router); + + init(): void { + this.itemBaseUrl = getItemPageRoute(this.item) + '/'; + if (this.tabs && this.tabs.length > 0) { + if (isNotNull(this.route.snapshot.paramMap.get('tab'))) { + this.parseTabs(this.route.snapshot.paramMap.get('tab')); + } else { + this.parseTabs(this.tabs[0].shortname); + } + } + } + + public parseTabs(shortname): void { + const tabs = []; + this.tabs.forEach((tab) => { + // create children where tab has "::" + if (tab.shortname.includes('::')) { + const splitedTabs = tab.shortname.split('::'); + const splitedHeaderTabs = tab.header.split('::'); + const previousTab = tabs.find((seltab) => seltab.shortname === splitedTabs[0]); + + if (!previousTab) { + const parentTab = Object.assign({}, tab); + parentTab.header = splitedHeaderTabs[0]; + parentTab.shortname = splitedTabs[0]; + const childTab = Object.assign(tab, { + header: splitedHeaderTabs[1], + shortname: splitedTabs[1], + }); + parentTab.children = []; + parentTab.children.push(childTab); + tabs.push(parentTab); + if (shortname === parentTab.shortname) { + this.setActiveTab(parentTab); + } + if (shortname === childTab.shortname) { + this.setActiveTab(childTab); + } + } else { + tab.header = splitedHeaderTabs[1]; + tab.shortname = splitedTabs[1]; + previousTab.children.push(tab); + } + if (shortname === tab.shortname) { + this.setActiveTab(tab); + } + } else { + tabs.push(tab); + if (shortname === tab.shortname) { + this.setActiveTab(tab); + } + } + }); + this.tabs = tabs; + } + + abstract emitSelected(selectedTab): void; + + setActiveTab(tab) { + this.activeTab$.next(tab); + this.emitSelected(tab); + } + + navigateToTab(tab: DynamicLayoutTab): void { + this.activeTab$.next(tab); + const itemPageRoute = (getItemPageRoute(this.item) + '/' + tab.shortname); + this.router.navigateByUrl(itemPageRoute, { onSameUrlNavigation: 'ignore' }).then(() => { + this.location.replaceState(itemPageRoute); + }); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.html b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.html new file mode 100644 index 00000000000..23421556965 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.html @@ -0,0 +1,48 @@ + diff --git a/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.scss b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.scss new file mode 100644 index 00000000000..551322c4b00 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.scss @@ -0,0 +1,101 @@ + +:host ::ng-deep { + + a{ + cursor: pointer; + } + + height: 100%; + + .fa-chevron-right { + padding: calc(var(--bs-spacer) / 2); + font-size: 0.5rem; + } + + .sidebar-sub-level-items { + list-style: none; + overflow: hidden; + + .nav-item { + background: transparent; + } + } + + .sidebar-collapsible { + display: flex; + flex-direction: column; + } + + .sidebar-section.expanded { + display: block; + color: rgba(0, 0, 0, 0.5); + } + + ul .active > .nav-item, .sidebar-section.active > div > .nav-item { + color: var(--ds-dynamic-layout-sidebar-menu-active-color); + background: var(--ds-dynamic-layout-sidebar-menu-active-background); + } + + .sidebar-section.active > .sidebar-collapsible > .nav-item { + background-color: transparent; + } + +} + +.nav-item { + padding: 10px 0; + color: var(--ds-dynamic-layout-navbar-link-color); + height: 100%; + justify-content: center; + align-items: center; + display: flex; +} + +.vertical { + .icon-wrapper { + border-bottom: var(--ds-dynamic-layout-sidebar-link-border-bottom); + } + + .sidebar-collapsible { + border-bottom: var(--ds-dynamic-layout-sidebar-link-border-bottom); + } + + .nav-item:hover{ + background-color: var(--ds-dynamic-layout-sidebar-tab-color-hover); + } +} + +.horizontal { + .icon-wrapper { + border-bottom: var(--ds-dynamic-layout-navbar-link-border-bottom); + height: 100%; + display: flex; + align-items: center; + } + + .nav-item { + padding: 10px; + } + + .sidebar-collapsible { + display: block; + } + + .sidebar-sub-level-items { + background-color: var(--ds-dynamic-layout-navbar-sub-level-backround-color); + } + + ul .active > .nav-item, .sidebar-section.active > div > .nav-item { + color: var(--ds-dynamic-layout-sidebar-menu-active-color); + background: var(--ds-dynamic-layout-sidebar-menu-active-background); + } + + .nav-item:hover{ + background-color: var(--ds-dynamic-layout-navbar-tab-color-hover); + } + +} + +.active.horizontal { + border-bottom: 2px solid var(--ds-dynamic-layout-navbar-tab-color-active); +} diff --git a/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.spec.ts new file mode 100644 index 00000000000..4c20e30d60b --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.spec.ts @@ -0,0 +1,47 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { tabPersonTest } from '@dspace/core/testing/layout-tab.mocks'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { DynamicLayoutLoaderDirective } from '../../../directives/dynamic-layout-loader.directive'; +import { DynamicLayoutSidebarItemComponent } from './dynamic-layout-sidebar-item.component'; + +describe('DynamicLayoutSidebarItemComponent', () => { + let component: DynamicLayoutSidebarItemComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + NoopAnimationsModule, DynamicLayoutSidebarItemComponent, + DynamicLayoutLoaderDirective], + providers: [ + { provide: BitstreamDataService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutSidebarItemComponent); + component = fixture.componentInstance; + component.tab = tabPersonTest; + fixture.detectChanges(); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.ts b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.ts new file mode 100644 index 00000000000..68f3d2d24f0 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-loader/shared/sidebar-item/dynamic-layout-sidebar-item.component.ts @@ -0,0 +1,121 @@ +import { NgClass } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; +import { TranslateService } from '@ngx-translate/core'; + +import { + rotate, + rotateNavbar, +} from '../../../../shared/animations/rotate'; +import { slide } from '../../../../shared/animations/slide'; + +/** + * This component defines the default layout for all tabs of DSpace Items. + * This component can be overwritten for a specific Item type using + * DynamicLayoutTabModelComponent decorator + */ +@Component({ + selector: 'ds-dynamic-layout-sidebar-item', + templateUrl: './dynamic-layout-sidebar-item.component.html', + styleUrls: ['./dynamic-layout-sidebar-item.component.scss'], + animations: [rotate, slide, rotateNavbar], + imports: [ + NgClass, + RouterLink, + ], +}) +export class DynamicLayoutSidebarItemComponent implements OnInit { + /** + * Contains the items base url + */ + @Input() itemBaseUrl: string; + + /** + * The tab that will be shown + */ + @Input() tab: DynamicLayoutTab; + + /** + * used for notify tab selection + */ + @Input() selectedTab: DynamicLayoutTab; + + /** + * used for specifying type of layout + */ + @Input() layout: string; + + /** + * The prefix used for box header's i18n key + */ + tabI18nPrefix = 'layout.tab.header.'; + + /** + * used for notify tab selection + */ + @Output() tabSelectedChange = new EventEmitter(); + + + @Input() activeTab: DynamicLayoutTab; + + /** + * Emits true when the menu section is expanded, else emits false + * This is true when the section is active AND either the sidebar or it's preview is open + */ + expanded = false; + + constructor(protected translateService: TranslateService) { + } + + ngOnInit() { + if (!!this.tab && !!this.tab.children && this.tab.children.length > 0) { + this.tab.isActive = false; + this.expanded = false; + this.tab.children.forEach((subtab) => { + if (subtab.id === this.activeTab.id) { + if (this.layout !== 'horizontal') { + this.expanded = true; + } + this.tab.isActive = true; + return; + } + }); + } + } + + /** + * get the translation for the i18n key + * @param key the i18n key + */ + getTranslation(key: string): string { + const value = this.translateService.instant(key); + return value === key ? null : value; + } + + getTabHeader(tab: DynamicLayoutTab): string { + const tabHeaderI18nKey = this.tabI18nPrefix + this.tab.entityType + '.' + tab.shortname; + const tabHeaderGenericI18nKey = this.tabI18nPrefix + tab.shortname; + + return this.getTranslation(tabHeaderI18nKey) ?? + this.getTranslation(tabHeaderGenericI18nKey) ?? + this.getTranslation(tab.header) ?? + tab.header ?? + ''; + } + + toggleSection(event): void { + this.expanded = !this.expanded; + } + + selectTab(tab): void { + this.tabSelectedChange.emit(tab); + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.html new file mode 100644 index 00000000000..31dcf590f47 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.html @@ -0,0 +1,44 @@ +@if ((owningCollection$ | async) || (mappedCollections$ | async).length > 0) { +
+ @if (owningCollection$ | async) { +
+
{{ 'dynamic-layout.rendering.collections.owning-collection.label' | translate }}
+ +
+ } + @if ((mappedCollections$ | async)?.length > 0) { +
+
{{ 'dynamic-layout.rendering.collections.mapped-collection.label' | translate }}
+
+ @for (mappedCollection of (mappedCollections$ | async); track mappedCollection; let last = $last) { +
+ {{ mappedCollection.firstMetadataValue('dc.title') }} + @if (isInline && !last) { + + } +
+ } + @if (isLoading$ | async) { +
+ {{'dynamic-layout.rendering.collections.loading' | translate}} +
+ } + @if ((isLoading$ | async) !== true && (hasMore$ | async) === true) { + + {{'dynamic-layout.rendering.collections.load-more' | translate}} + + } +
+
+ } +
+} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.spec.ts new file mode 100644 index 00000000000..bb220fda1d0 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.spec.ts @@ -0,0 +1,159 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; +import { CollectionDataService } from '@dspace/core/data/collection-data.service'; +import { + buildPaginatedList, + PaginatedList, +} from '@dspace/core/data/paginated-list.model'; +import { DynamicLayoutBox } from '@dspace/core/layout/models/box.model'; +import { Collection } from '@dspace/core/shared/collection.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { PageInfo } from '@dspace/core/shared/page-info.model'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { DynamicLayoutCollectionBoxComponent } from './dynamic-layout-collection-box.component'; + +describe('DynamicLayoutCollectionBoxComponent', () => { + let component: DynamicLayoutCollectionBoxComponent; + let fixture: ComponentFixture; + + const createMockCollection = (id: string) => Object.assign(new Collection(), { + id: id, + name: `collection-${id}`, + }); + + const testBox = Object.assign(new DynamicLayoutBox(), { + 'id': 1, + 'shortname': 'collections', + 'header': 'Collections', + 'entityType': 'Publication', + 'collapsed': false, + 'minor': false, + 'style': null, + 'security': 0, + 'boxType': 'COLLECTIONS', + 'maxColumns': null, + 'configuration': null, + 'metadataSecurityFields': [], + 'container': false, + }); + let collectionDataService; + let mockCollection1: Collection; + let mockPage1: PaginatedList; + + const owningCollection = Object.assign(new Collection(), { uuid: 'test-collection-uuid' }); + + const owningCollection$ = createSuccessfulRemoteDataObject$(owningCollection); + + const testItem = Object.assign(new Item(), { + type: 'item', + owningCollection: owningCollection$, + uuid: 'test-item-uuid', + }); + + mockCollection1 = createMockCollection('c1'); + + beforeEach(async () => { + collectionDataService = jasmine.createSpyObj([ + 'findOwningCollectionFor', + 'findMappedCollectionsFor', + ]); + + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + DynamicLayoutCollectionBoxComponent, + ], + providers: [ + { provide: 'boxProvider', useValue: testBox }, + { provide: 'itemProvider', useValue: testItem }, + { provide: CollectionDataService, useValue: collectionDataService }, + { provide: ActivatedRoute, useValue: {} }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutCollectionBoxComponent); + component = fixture.componentInstance; + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 0, + totalElements: 0, + }), []); + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render container', () => { + expect(fixture.debugElement.query(By.css('.container'))).not.toBeNull(); + }); + + describe('without collections', () => { + beforeEach(() => { + component.owningCollection$ = of(null); + fixture.detectChanges(); + }); + + it('should not render container', () => { + expect(fixture.debugElement.query(By.css('.container'))).toBeNull(); + }); + }); + + describe('without owning collections', () => { + beforeEach(() => { + component.owningCollection$ = of(null); + fixture.detectChanges(); + }); + + it('should not render owning collection row', () => { + expect(fixture.debugElement.query(By.css('div[data-test="owningCollection"]'))).toBeNull(); + }); + }); + + describe('with owning collections', () => { + beforeEach(() => { + component.owningCollection$ = of(mockCollection1); + fixture.detectChanges(); + }); + + it('should render owning collection row', () => { + expect(fixture.debugElement.query(By.css('div[data-test="owningCollection"]'))).not.toBeNull(); + }); + }); + + describe('without mapped collections', () => { + beforeEach(() => { + component.mappedCollections$ = of([]); + fixture.detectChanges(); + }); + + it('should not render mapped collections row', () => { + expect(fixture.debugElement.query(By.css('div[data-test="mappedCollections"]'))).toBeNull(); + }); + }); + + describe('with mapped collections', () => { + beforeEach(() => { + component.mappedCollections$ = of([mockCollection1]); + fixture.detectChanges(); + }); + + it('should render mapped collection row', () => { + expect(fixture.debugElement.query(By.css('div[data-test="mappedCollections"]'))).not.toBeNull(); + }); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.ts new file mode 100644 index 00000000000..2968e6ea4b8 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/dynamic-layout-collection-box/dynamic-layout-collection-box.component.ts @@ -0,0 +1,160 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { CollectionDataService } from '@dspace/core/data/collection-data.service'; +import { FindListOptions } from '@dspace/core/data/find-list-options.model'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { DynamicLayoutBox } from '@dspace/core/layout/models/box.model'; +import { Collection } from '@dspace/core/shared/collection.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, + getPaginatedListPayload, +} from '@dspace/core/shared/operators'; +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, + Observable, + of, +} from 'rxjs'; +import { + map, + shareReplay, + tap, +} from 'rxjs/operators'; + +import { environment } from '../../../../../../environments/environment'; +import { DynamicLayoutBoxModelComponent } from '../../../../models/dynamic-layout-box-component.model'; + +@Component({ + selector: 'ds-dynamic-layout-collection-box', + templateUrl: './dynamic-layout-collection-box.component.html', + styleUrls: ['./dynamic-layout-collection-box.component.scss'], + imports: [ + AsyncPipe, + NgClass, + RouterLink, + TranslateModule, + ], +}) +export class DynamicLayoutCollectionBoxComponent extends DynamicLayoutBoxModelComponent implements OnInit { + + isInline = environment.layout.collectionsBox.isInline; + + /** + * Amount of mapped collections that should be fetched at once. + */ + pageSize = 5; + + /** + * Last page of the mapped collections that has been fetched. + */ + lastPage = 0; + + /** + * Whether or not a page of mapped collections is currently being loaded. + */ + isLoading$: BehaviorSubject = new BehaviorSubject(true); + + /** + * Whether or not more pages of mapped collections are available. + */ + hasMore$: BehaviorSubject = new BehaviorSubject(true); + + /** + * This includes the owning collection + */ + owningCollection$: Observable; + + /** + * This includes the mapped collection + */ + mappedCollections$: Observable = of([]); + + constructor( + protected translateService: TranslateService, + @Inject('boxProvider') public boxProvider: DynamicLayoutBox, + @Inject('itemProvider') public itemProvider: Item, + private cds: CollectionDataService, + ) { + super(translateService, boxProvider, itemProvider); + } + + ngOnInit(): void { + super.ngOnInit(); + + this.owningCollection$ = this.item.owningCollection.pipe( + getFirstSucceededRemoteDataPayload(), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + this.handleLoadMore(); + } + + handleLoadMore() { + this.isLoading$.next(true); + const newMappedCollections$ = this.loadMappedCollectionPage(); + this.mappedCollections$ = combineLatest([this.mappedCollections$, newMappedCollections$]).pipe( + map(([mappedCollections, newMappedCollections]: [Collection[], Collection[]]) => { + return [...mappedCollections, ...newMappedCollections].filter(collection => hasValue(collection)); + }), + ); + } + + loadMappedCollectionPage(): Observable { + return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), { + elementsPerPage: this.pageSize, + currentPage: this.lastPage + 1, + })).pipe( + getFirstCompletedRemoteData>(), + + // update isLoading$ + tap(() => this.isLoading$.next(false)), + + getFirstSucceededRemoteDataPayload(), + + // update lastPage + tap((response: PaginatedList) => this.lastPage = response.currentPage), + + // update hasMore$ + tap((response: PaginatedList) => this.hasMore$.next(this.lastPage < response.totalPages)), + + getPaginatedListPayload(), + ); + } + + /** + * Returns a string representing the style of field label if exists + */ + get labelStyle(): string { + return environment.layout.collectionsBox.defaultCollectionsLabelColStyle; + } + + /** + * Returns a string representing the style of field value if exists + */ + get valueStyle(): string { + return environment.layout.collectionsBox.defaultCollectionsValueColStyle; + } + + /** + * Returns a string representing the style of row if exists + */ + get rowStyle(): string { + return environment.layout.collectionsBox.defaultCollectionsRowStyle; + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.html new file mode 100644 index 00000000000..46bcbe39d02 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.spec.ts new file mode 100644 index 00000000000..667ce528691 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.spec.ts @@ -0,0 +1,100 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { DynamicLayoutBox } from '@dspace/core/layout/models/box.model'; +import { RouteService } from '@dspace/core/services/route.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { MockActivatedRoute } from '@dspace/core/testing/active-router.mock'; +import { AuthServiceMock } from '@dspace/core/testing/auth.service.mock'; +import { routeServiceStub } from '@dspace/core/testing/route-service.stub'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { MiradorViewerComponent } from '../../../../../item-page/mirador-viewer/mirador-viewer.component'; +import { DynamicLayoutIiifViewerBoxComponent } from './dynamic-layout-iiif-viewer-box.component'; + +describe('DynamicLayoutIiifViewerBoxComponent', () => { + let component: DynamicLayoutIiifViewerBoxComponent; + let fixture: ComponentFixture; + + const testItem = Object.assign(new Item(), { + type: 'item', + entityType: 'Publication', + metadata: { + 'dc.title': [{ + 'value': 'test item title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }], + 'dspace.iiif.enabled': [{ + 'value': 'true', + 'language': null, + 'authority': null, + 'confidence': 0, + 'place': 0, + 'securityLevel': 0, + }], + }, + uuid: 'test-item-uuid', + }); + + const testBox = Object.assign(new DynamicLayoutBox(), { + 'id': 1, + 'shortname': 'iiifviewer', + 'header': 'IIIF Viewer', + 'entityType': 'Publication', + 'collapsed': false, + 'minor': false, + 'style': null, + 'security': 0, + 'boxType': 'IIIFVIEWER', + 'maxColumns': null, + 'configuration': null, + 'metadataSecurityFields': [], + 'container': false, + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + DynamicLayoutIiifViewerBoxComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: 'boxProvider', useValue: testBox }, + { provide: 'itemProvider', useValue: testItem }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: RouteService, useValue: routeServiceStub }, + ], + }).overrideComponent(DynamicLayoutIiifViewerBoxComponent, { remove: { imports: [MiradorViewerComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutIiifViewerBoxComponent); + component = fixture.componentInstance; + component.box = testBox; + component.item = testItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.ts new file mode 100644 index 00000000000..6f5cfe57bc2 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/iiif-viewer/dynamic-layout-iiif-viewer-box.component.ts @@ -0,0 +1,51 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { DynamicLayoutBox } from '@dspace/core/layout/models/box.model'; +import { RouteService } from '@dspace/core/services/route.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { getDSpaceQuery } from '@dspace/core/utilities/item-iiif-utils'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { MiradorViewerComponent } from '../../../../../item-page/mirador-viewer/mirador-viewer.component'; +import { DynamicLayoutBoxModelComponent } from '../../../../models/dynamic-layout-box-component.model'; + +@Component({ + selector: 'ds-dynamic-layout-iiif-viewer-box', + templateUrl: './dynamic-layout-iiif-viewer-box.component.html', + styleUrls: ['./dynamic-layout-iiif-viewer-box.component.scss'], + imports: [ + AsyncPipe, + MiradorViewerComponent, + ], +}) +export class DynamicLayoutIiifViewerBoxComponent extends DynamicLayoutBoxModelComponent implements OnInit { + + isSearchable: boolean; + query$: Observable; + + constructor( + protected translateService: TranslateService, + public authService: AuthService, + protected routeService: RouteService, + @Inject('boxProvider') public boxProvider: DynamicLayoutBox, + @Inject('itemProvider') public itemProvider: Item, + ) { + super(translateService, boxProvider, itemProvider); + } + + ngOnInit() { + super.ngOnInit(); + this.isSearchable = this.item.firstMetadataValue('iiif.search.enabled') === 'true'; + // Search is not working: error 501 "Not Implemented" + if (this.isSearchable) { + this.query$ = getDSpaceQuery(this.item, this.routeService); + } + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.html new file mode 100644 index 00000000000..094de3a7abe --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.html @@ -0,0 +1,11 @@ +
+ + @for (row of metadataBoxConfiguration?.rows; track row) { +
+
+ } +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.scss new file mode 100644 index 00000000000..87bdfb51bbb --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.scss @@ -0,0 +1,7 @@ +// :host{ +// display:flex; +// // flex:1; +// } +// ngb-accordion{ +// flex-grow:1; +// } diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.spec.ts new file mode 100644 index 00000000000..9a69b6835f3 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.spec.ts @@ -0,0 +1,74 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Item } from '@dspace/core/shared/item.model'; +import { boxMetadata } from '@dspace/core/testing/box.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { DynamicLayoutLoaderDirective } from '../../../../directives/dynamic-layout-loader.directive'; +import { DynamicLayoutMetadataBoxComponent } from './dynamic-layout-metadata-box.component'; +import { TextComponent } from './rendering-types/text/text.component'; +import { RowComponent } from './row/row.component'; + +describe('DynamicLayoutMetadataBoxComponent', () => { + let component: DynamicLayoutMetadataBoxComponent; + let fixture: ComponentFixture; + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.title': [{ + 'value': 'test item title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }], + }, + uuid: 'test-item-uuid', + }, + ); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, DynamicLayoutMetadataBoxComponent, + DynamicLayoutLoaderDirective, + RowComponent, + TextComponent], + providers: [ + { provide: 'boxProvider', useValue: boxMetadata }, + { provide: 'itemProvider', useValue: testItem }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(DynamicLayoutMetadataBoxComponent, { remove: { imports: [RowComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutMetadataBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('check rows rendering', (done) => { + const rowsFound = fixture.debugElement.queryAll(By.css('div[ds-row]')); + + expect(rowsFound.length).toEqual(2); + done(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.ts new file mode 100644 index 00000000000..c74b97988a9 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/dynamic-layout-metadata-box.component.ts @@ -0,0 +1,77 @@ + +import { + ChangeDetectorRef, + Component, + Inject, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + DynamicLayoutBox, + MetadataBoxConfiguration, +} from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { Subscription } from 'rxjs'; + +import { DynamicLayoutBoxModelComponent } from '../../../../models/dynamic-layout-box-component.model'; +import { RowComponent } from './row/row.component'; + +/** + * This component renders the metadata boxes of items + */ +@Component({ + selector: 'ds-dynamic-layout-metadata-box', + templateUrl: './dynamic-layout-metadata-box.component.html', + styleUrls: ['./dynamic-layout-metadata-box.component.scss'], + imports: [ + RowComponent, + ], +}) +/** + * For overwrite this component create a new one that extends DynamicLayoutBoxObj and + * add the DynamicLayoutBoxModelComponent decorator indicating the type of box to overwrite + */ +export class DynamicLayoutMetadataBoxComponent extends DynamicLayoutBoxModelComponent implements OnInit, OnDestroy { + + /** + * Contains the fields configuration for current box + */ + metadataBoxConfiguration: MetadataBoxConfiguration; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + constructor( + public cdr: ChangeDetectorRef, + protected translateService: TranslateService, + @Inject('boxProvider') public boxProvider: DynamicLayoutBox, + @Inject('itemProvider') public itemProvider: Item, + ) { + super(translateService, boxProvider, itemProvider); + } + + ngOnInit() { + super.ngOnInit(); + this.setMetadataComponents(this.box.configuration as MetadataBoxConfiguration); + } + + /** + * Set the metadataBoxConfiguration. + * @param metadatacomponents + */ + setMetadataComponents(metadatacomponents: MetadataBoxConfiguration) { + this.metadataBoxConfiguration = metadatacomponents; + this.cdr.detectChanges(); + } + + /** + * Unsubscribes all subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.html new file mode 100644 index 00000000000..a7737d33de1 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.html @@ -0,0 +1,12 @@ +
+ @for (attachment of (bitstreams$ | async); track attachment; let last = $last) { + + } + @if (canViewMore) { +
+ +
+ } +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.scss new file mode 100644 index 00000000000..2cece24b0f7 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.scss @@ -0,0 +1,5 @@ + + +.hint { + cursor: pointer; +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.spec.ts new file mode 100644 index 00000000000..738b1b1c08d --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.spec.ts @@ -0,0 +1,369 @@ +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { attachmentsMock } from '@dspace/core/testing/attachments.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { + Observable, + of, +} from 'rxjs'; + +import { ThemedFileDownloadLinkComponent } from '../../../../../../../shared/file-download-link/themed-file-download-link.component'; +import { TruncatableComponent } from '../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { FileSizePipe } from '../../../../../../../shared/utils/file-size-pipe'; +import { FieldRenderingType } from '../field-rendering-type'; +import { AdvancedAttachmentComponent } from './advanced-attachment.component'; +import { BitstreamAttachmentComponent } from './bitstream-attachment/bitstream-attachment.component'; + +describe('AdvancedAttachmentComponent', () => { + let component: AdvancedAttachmentComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const testItem = Object.assign(new Item(), { + bundles: of({}), + metadata: { + 'dc.identifier.doi': [ + { + value: 'doi:10.1392/dironix', + }, + ], + }, + _links: { + self: { href: 'obj-selflink' }, + }, + uuid: 'item-uuid', + }); + + const mockField: LayoutField = { + metadata: '', + label: 'Files', + rendering: FieldRenderingType.ADVANCEDATTACHMENT, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + bitstream: { + bundle: 'ORIGINAL', + metadataField: null, + metadataValue: null, + }, + }; + + const mockFieldWithMetadata: LayoutField = { + metadata: '', + label: 'Files', + rendering: FieldRenderingType.ADVANCEDATTACHMENT, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: 'main article', + }, + }; + + const mockFieldWithRegexMetadata: LayoutField = { + metadata: '', + label: 'Files', + rendering: FieldRenderingType.ADVANCEDATTACHMENT, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: '(/^Test Article/i)', + }, + }; + + const bitstream1 = Object.assign(new Bitstream(), { + id: 'bitstream4', + uuid: 'bitstream4', + metadata: { + 'dc.title': [ + { + value: 'test', + }, + ], + 'dc.type': [ + { + value: 'test', + }, + ], + 'dc.description': [ + { + value: 'test', + }, + ], + }, + _links: { + self: { href: 'obj-selflink' }, + }, + }); + const bitstream2 = Object.assign(new Bitstream(), { + id: 'bitstream4', + uuid: 'bitstream4', + metadata: {}, + _links: { + self: { href: 'obj-selflink' }, + }, + }); + + const mockBitstreamDataService: any = jasmine.createSpyObj('BitstreamDataService', { + getThumbnailFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new Bitstream()); + }, + findAllByItemAndBundleName: jasmine.createSpy('findAllByItemAndBundleName'), + findByItem: jasmine.createSpy('findByItem'), + }); + + const mockAuthorizedService = jasmine.createSpyObj('AuthorizationDataService', { + isAuthorized: jasmine.createSpy('isAuthorized'), + }); + + const getDefaultTestBedConf = () => { + return { + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + AdvancedAttachmentComponent, + RouterTestingModule, + FileSizePipe, + ], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: AuthorizationDataService, useValue: mockAuthorizedService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }; + }; + + describe('when metadata configuration is present', () => { + + describe('and pagination is disabled', () => { + beforeEach(() => { + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AdvancedAttachmentComponent, { + remove: { + imports: [ + BitstreamAttachmentComponent, + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AdvancedAttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + mockAuthorizedService.isAuthorized.and.returnValues(of(true), of(true)); + component.envPagination.enabled = false; + mockBitstreamDataService.findAllByItemAndBundleName.and.returnValues(createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1]))); + mockBitstreamDataService.findByItem.and.returnValues(createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1]))); + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList(attachmentsMock))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve bitstreams without pagination', fakeAsync(() => { + flush(); + expect(component.getBitstreamsByItem).toHaveBeenCalled(); + })); + + it('should not show view more button', fakeAsync(() => { + flush(); + expect(fixture.debugElement.query(By.css('button[data-test="view-more"]'))).toBeNull(); + })); + + it('should render the proper entry number', fakeAsync(() => { + flush(); + const valueFound = fixture.debugElement.queryAll(By.css('[data-test="attachment-info"]')); + expect(valueFound.length).toBe(3); + })); + + }); + + describe('when pagination is enabled', () => { + beforeEach(() => { + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AdvancedAttachmentComponent, { + remove: { + imports: [ + BitstreamAttachmentComponent, + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AdvancedAttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + mockAuthorizedService.isAuthorized.and.returnValues(of(true), of(true)); + component.envPagination.enabled = true; + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstream1, bitstream1]))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should show view more button', () => { + expect(fixture.debugElement.query(By.css('button[data-test="view-more"]'))).toBeTruthy(); + }); + + it('should show 2 elements', () => { + expect(fixture.debugElement.queryAll(By.css('[data-test="attachment-info"]')).length).toEqual(2); + }); + + it('and view more button is clicked it should show 4 elements', () => { + (component.getBitstreamsByItem as any).and.returnValue(of(createPaginatedList([bitstream1, bitstream1]))); + const btn = fixture.debugElement.query(By.css('button[data-test="view-more"]')); + fixture.detectChanges(); + btn.nativeElement.click(); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css('[data-test="attachment-info"]')).length).toEqual(4); + }); + + }); + }); + + describe('when metadata configuration is not present', () => { + + describe('and pagination is disabled', () => { + beforeEach(() => { + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AdvancedAttachmentComponent, { + remove: { + imports: [ + BitstreamAttachmentComponent, + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AdvancedAttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + mockAuthorizedService.isAuthorized.and.returnValues(of(true), of(true)); + component.envPagination.enabled = false; + component.envMetadata = []; + mockBitstreamDataService.findAllByItemAndBundleName.and.returnValues(createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1]))); + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList(attachmentsMock))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve bitstreams without pagination', fakeAsync(() => { + flush(); + expect(component.getBitstreamsByItem).toHaveBeenCalled(); + })); + + it('should not show view more button', fakeAsync(() => { + flush(); + expect(fixture.debugElement.query(By.css('button[data-test="view-more"]'))).toBeNull(); + })); + + it('should render the proper entry number', fakeAsync(() => { + flush(); + const valueFound = fixture.debugElement.queryAll(By.css('[data-test="attachment-info"]')); + expect(valueFound.length).toBe(3); + })); + + }); + + describe('when pagination is enabled', () => { + beforeEach(() => { + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AdvancedAttachmentComponent, { + remove: { + imports: [ + BitstreamAttachmentComponent, + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AdvancedAttachmentComponent); + component = fixture.componentInstance; + component.envMetadata = []; + de = fixture.debugElement; + mockAuthorizedService.isAuthorized.and.returnValues(of(true), of(true)); + component.envPagination.enabled = true; + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstream1, bitstream1]))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should show view more button', () => { + expect(fixture.debugElement.query(By.css('button[data-test="view-more"]'))).toBeTruthy(); + }); + + it('should show 2 elements', () => { + expect(fixture.debugElement.queryAll(By.css('[data-test="attachment-info"]')).length).toEqual(2); + }); + + it('and view more button is clicked it should show 4 elements', () => { + const btn = fixture.debugElement.query(By.css('button[data-test="view-more"]')); + (component.getBitstreamsByItem as any).and.returnValue(of(createPaginatedList([bitstream1, bitstream1]))); + fixture.detectChanges(); + btn.nativeElement.click(); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css('[data-test="attachment-info"]')).length).toEqual(4); + }); + + }); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.ts new file mode 100644 index 00000000000..5a09d507c4b --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/advanced-attachment.component.ts @@ -0,0 +1,76 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { FindListOptions } from '@dspace/core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '@dspace/core/data/paginated-list.model'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { followLink } from '@dspace/core/shared/follow-link-config.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { environment } from '../../../../../../../../environments/environment'; +import { AttachmentComponent } from '../attachment/attachment.component'; +import { BitstreamAttachmentComponent } from './bitstream-attachment/bitstream-attachment.component'; + +@Component({ + selector: 'ds-advanced-attachment', + templateUrl: './advanced-attachment.component.html', + styleUrls: ['./advanced-attachment.component.scss'], + imports: [ + AsyncPipe, + BitstreamAttachmentComponent, + TranslateModule, + ], +}) +/** + * This component renders the attachment with an advanced layout. + */ +export class AdvancedAttachmentComponent extends AttachmentComponent implements OnInit { + + /** + * Environment variables configuring the fields to be viewed + */ + envMetadata = environment.layout.advancedAttachmentRendering.metadata; + + /** + * Environment variables configuring pagination + */ + envPagination = environment.layout.advancedAttachmentRendering.pagination; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected bitstreamDataService: BitstreamDataService, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, bitstreamDataService, translateService); + } + + getBitstreamsByItem(options?: FindListOptions): Observable> { + return this.bitstreamDataService + .showableByItem(this.item.uuid, this.field.bitstream.bundle, this.getMetadataFilters(), options, false, false, followLink('thumbnail'), followLink('format')) + .pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData>) => { + return response.hasSucceeded ? response.payload : buildPaginatedList(null, []); + }), + ); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.html new file mode 100644 index 00000000000..466ca5711a5 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.html @@ -0,0 +1 @@ + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.spec.ts new file mode 100644 index 00000000000..4de764c00e3 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.spec.ts @@ -0,0 +1,63 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; +import { AuthorizationDataServiceStub } from '@dspace/core/testing/authorization-service.stub'; +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 { AttachmentRenderComponent } from './attachment-render.component'; + +describe('AttachmentRenderComponent', () => { + let component: AttachmentRenderComponent; + let fixture: ComponentFixture; + let configurationDataService: ConfigurationDataService; + + beforeEach(async () => { + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.type', + values: [], + })), + }); + + await TestBed.configureTestingModule({ + imports: [ + AttachmentRenderComponent, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + providers: [ + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AttachmentRenderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.ts new file mode 100644 index 00000000000..01eb3620daa --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/attachment-render.component.ts @@ -0,0 +1,115 @@ +import { + Component, + ComponentRef, + inject, + Injector, + Input, + OnInit, + ViewChild, + ViewContainerRef, +} from '@angular/core'; + +import { Bitstream } from '../../../../../../../../../core/shared/bitstream.model'; +import { GenericConstructor } from '../../../../../../../../../core/shared/generic-constructor'; +import { Item } from '../../../../../../../../../core/shared/item.model'; +import { DynamicLayoutLoaderDirective } from '../../../../../../../../directives/dynamic-layout-loader.directive'; +import { + AttachmentRenderingType, + getAttachmentTypeRendering, +} from '../attachment-type.decorator'; + +@Component({ + selector: 'ds-attachment-render', + templateUrl: './attachment-render.component.html', + styleUrls: ['./attachment-render.component.scss'], + imports: [ + DynamicLayoutLoaderDirective, + ], +}) +export class AttachmentRenderComponent implements OnInit { + + /** + * Current DSpace Item + */ + @Input() item: Item; + /** + * The bitstream + */ + @Input() bitstream: Bitstream; + /** + * The bitstream + */ + @Input() renderingType: AttachmentRenderingType | string; + /** + * The tab name + */ + @Input() tabName: string; + + /** + * Directive hook used to place the dynamic render component + */ + @ViewChild('attachmentValue', { + static: true, + read: ViewContainerRef, + }) attachmentValueViewRef: ViewContainerRef; + + private injector: Injector = inject(Injector); + + ngOnInit(): void { + this.attachmentValueViewRef.clear(); + this.generateComponentRef(); + } + + /** + * Generate ComponentFactory for attachment rendering + */ + computeComponentFactory(): GenericConstructor { + const rendering = this.computeRendering(); + const attachmentTypeRenderOptions = getAttachmentTypeRendering(rendering); + return attachmentTypeRenderOptions?.componentRef || null; + } + + /** + * Generate ComponentRef for attachment rendering + */ + generateComponentRef(): ComponentRef { + let attachmentComponentRef: ComponentRef; + const component: GenericConstructor = this.computeComponentFactory(); + if (component) { + attachmentComponentRef = this.attachmentValueViewRef.createComponent(component, { + index: 0, + injector: this.getComponentInjector(), + }); + (attachmentComponentRef.instance as any).item = this.item; + (attachmentComponentRef.instance as any).bitstream = this.bitstream; + (attachmentComponentRef.instance as any).tabName = this.tabName; + } + return attachmentComponentRef; + } + + /** + * Generate Component Injector object + */ + getComponentInjector() { + const providers = [ + { provide: 'itemProvider', useValue: this.item, deps: [] }, + { provide: 'bitstreamProvider', useValue: this.bitstream, deps: [] }, + { provide: 'tabNameProvider', useValue: this.tabName, deps: [] }, + ]; + + return Injector.create({ + providers: providers, + parent: this.injector, + }); + } + + /** + * Return the rendering type of the field to render + * + * @return the rendering type + */ + computeRendering(): string | AttachmentRenderingType { + return this.renderingType || AttachmentRenderingType.DOWNLOAD; + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.html new file mode 100644 index 00000000000..77c206e5dac --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.html @@ -0,0 +1,21 @@ +@if (!hasNoDownload) { + @if (bitstreamPath$ | async; as bitstreamLink) { + @if (canDownload$ | async) { + + {{ 'dynamic-layout.advanced-attachment.download' | translate }} + + } @else { + + {{ 'dynamic-layout.advanced-attachment.requestACopy' | translate }} + + } + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.spec.ts new file mode 100644 index 00000000000..5a0b960d035 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.spec.ts @@ -0,0 +1,118 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; +import { Item } from 'src/app/core/shared/item.model'; + +import { FileDownloadButtonComponent } from './file-download-button.component'; + +describe('FileDownloadButtonComponent', () => { + let component: FileDownloadButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + + let bitstream: Bitstream; + let item: Item; + let configurationDataService: ConfigurationDataService; + + function init() { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: jasmine.createSpy('isAuthorized'), + }); + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + _links: { + self: { href: 'obj-selflink' }, + }, + }); + item = Object.assign(new Item(), { + uuid: 'itemUuid', + _links: { + self: { href: 'obj-selflink' }, + }, + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.type', + values: [], + })), + }); + } + + + beforeEach(async () => { + + init(); + + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + FileDownloadButtonComponent, + ], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + ], + }) + .compileComponents(); + }); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadButtonComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(true)); + component.bitstreamPath$ = of({ + routerLink: 'test', + queryParams: {}, + }); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show download button', () => { + expect(fixture.debugElement.query(By.css('[data-test="download"]'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('[data-test="requestACopy"]'))).toBeFalsy(); + }); + + it('should show can request a copy button', () => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('[data-test="download"]'))).toBeFalsy(); + expect(fixture.debugElement.query(By.css('[data-test="requestACopy"]'))).toBeTruthy(); + }); + + it('should show a disabled can request a copy button when request.item.type has no value', () => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); + component.ngOnInit(); + fixture.detectChanges(); + const btn = fixture.debugElement.query(By.css('[data-test="requestACopy"]')); + expect(btn.nativeNode.getAttribute('aria-disabled')).toBe('true'); + expect(btn.nativeNode.classList.contains('disabled')).toBeTrue(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.ts new file mode 100644 index 00000000000..1a13494eef1 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.ts @@ -0,0 +1,35 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { BtnDisabledDirective } from 'src/app/shared/btn-disabled.directive'; + +import { FileDownloadLinkComponent } from '../../../../../../../../../../../shared/file-download-link/file-download-link.component'; + +@Component({ + selector: 'ds-file-download-button', + templateUrl: './file-download-button.component.html', + styleUrls: ['./file-download-button.component.scss'], + imports: [ + AsyncPipe, + BtnDisabledDirective, + RouterLink, + TranslateModule, + ], +}) +/** + * Component displaying a download button or the request a copy button depending on authorization + */ +export class FileDownloadButtonComponent extends FileDownloadLinkComponent implements OnInit { + + hasNoDownload = true; + + ngOnInit() { + super.ngOnInit(); + this.hasNoDownload = this.bitstream?.allMetadataValues('bitstream.viewer.provider').includes('nodownload'); + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-type.decorator.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-type.decorator.ts new file mode 100644 index 00000000000..66169563179 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/attachment-type.decorator.ts @@ -0,0 +1,38 @@ +import { Component } from '@angular/core'; + +import { GenericConstructor } from '../../../../../../../../core/shared/generic-constructor'; +import { FileDownloadButtonComponent } from './attachment-render/types/file-download-button/file-download-button.component'; + +export enum AttachmentRenderingType { + DOWNLOAD = 'DOWNLOAD', + IIIF = 'IIIF', + PDF = 'PDF' +} + +const fieldType = new Map(); + +fieldType.set(AttachmentRenderingType.DOWNLOAD, { + componentRef: FileDownloadButtonComponent, + structured: true, +} as AttachmentTypeFieldRenderOptions); + + +export interface AttachmentTypeFieldRenderOptions { + componentRef: GenericConstructor; + structured: boolean; +} + +export function AttachmentTypeRendering(objectType: AttachmentRenderingType, structured = false) { + return function decorator(component: any) { + if (objectType) { + fieldType.set(objectType, { + componentRef: component, + structured: structured, + } as AttachmentTypeFieldRenderOptions); + } + }; +} + +export function getAttachmentTypeRendering(objectType: string): AttachmentTypeFieldRenderOptions { + return fieldType.get(objectType.toUpperCase()); +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.html new file mode 100644 index 00000000000..eac76939e1c --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.html @@ -0,0 +1,83 @@ +@if (attachment) { +
+ +
+ +
+ +
+
+ @for (renderingType of allAttachmentProviders; track $index) { +
+ +
+ } + +
+ +
+
+
+ +
+ @for (attachmentConf of envMetadata; track $index) { + @if (attachment.firstMetadataValue(attachmentConf.name) || attachmentConf.type === AdvancedAttachmentElementType.Attribute) { +
+ {{ 'dynamic-layout.advanced-attachment.' + attachmentConf.name | translate }} + + @if (attachmentConf.type === AdvancedAttachmentElementType.Metadata) { + + @if(!attachmentConf.truncatable && attachmentConf.name === attachmentTypeMetadata) { +

+ {{attachment.firstMetadataValue(attachmentConf.name) | titlecase}} +

+ } + + @if(!attachmentConf.truncatable && attachmentConf.name !== attachmentTypeMetadata) { +

+ {{attachment.firstMetadataValue(attachmentConf.name)}} +

+ } + + @if (attachmentConf.truncatable) { + + + {{attachment.firstMetadataValue(attachmentConf.name)}} + + + } + } + @if (attachmentConf.type === AdvancedAttachmentElementType.Attribute) { + @if (attachmentConf.name === 'format') { + @if ((bitstreamFormat$ | async) === null || (bitstreamFormat$ | async) === undefined) { +

+ {{'dynamic-layout.advanced-attachment.label.not-present' | translate}} +

+ } @else { +

{{(bitstreamFormat$ | async)}}

+ } + } + + @if (attachmentConf.name === 'size' && bitstreamSize) { +

{{bitstreamSize | dsFileSize}}

+ } + + @if (attachmentConf.name === 'checksum') { + @if (checksumInfo?.value === null || checksumInfo?.value === undefined) { +

+ {{'dynamic-layout.advanced-attachment.label.not-present' | translate}} +

+ } @else { +

({{checksumInfo.checkSumAlgorithm}}):{{ checksumInfo.value }}

+ } + } + } + +
+ } + } + +
+ +
+} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.scss new file mode 100644 index 00000000000..e2c8b57a933 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.scss @@ -0,0 +1,29 @@ +.thumbnail-placeholder { + border: var(--ds-thumbnail-placeholder-border); + color: var(--ds-thumbnail-placeholder-color); + font-weight: var(--ds-advanced-attachment-thumbnail-placeholder-font-weight); +} + +.divider { + border-left: 1px solid var(--bs-gray-200); +} + +.card-container { + background-color: var(--bs-gray-100); + background-clip: border-box; + border-radius: 0.25rem; + padding: 1rem; +} + +i.custom-icon { + display: inline-block; + width: 20px; + height: 20px; + background-size: contain; + text-align: center; + vertical-align: middle; +} + +.thumbnail-wrapper { + min-width: 120px; +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.spec.ts new file mode 100644 index 00000000000..1529f0cee2a --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.spec.ts @@ -0,0 +1,71 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +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 { TruncatableComponent } from '../../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { ThemedThumbnailComponent } from '../../../../../../../../thumbnail/themed-thumbnail.component'; +import { BitstreamRenderingModelComponent } from '../../bitstream-rendering-model'; +import { AttachmentRenderComponent } from './attachment-render/attachment-render.component'; +import { BitstreamAttachmentComponent } from './bitstream-attachment.component'; + +describe('BitstreamAttachmentComponent', () => { + let component: BitstreamAttachmentComponent; + let fixture: ComponentFixture; + const attachmentMock: any = Object.assign(new Bitstream(), + { + checkSum: { + checkSumAlgorithm: 'MD5', + value: 'checksum', + }, + thumbnail: createSuccessfulRemoteDataObject$(new Bitstream()), + }, + ); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + BitstreamAttachmentComponent, + BitstreamRenderingModelComponent, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + providers: [ + provideRouter([]), + { provide: 'fieldProvider', useValue: {} }, + { provide: 'itemProvider', useValue: {} }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: BitstreamDataService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(BitstreamAttachmentComponent, { remove: { imports: [ThemedThumbnailComponent, AttachmentRenderComponent, TruncatableComponent, TruncatablePartComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamAttachmentComponent); + component = fixture.componentInstance; + component.attachment = attachmentMock; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.ts new file mode 100644 index 00000000000..6b9f04c12d0 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/advanced-attachment/bitstream-attachment/bitstream-attachment.component.ts @@ -0,0 +1,130 @@ +import { + AsyncPipe, + TitleCasePipe, +} from '@angular/common'; +import { + Component, + Inject, + Input, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; + +import { AdvancedAttachmentElementType } from '../../../../../../../../../config/advanced-attachment-rendering.config'; +import { environment } from '../../../../../../../../../environments/environment'; +import { BitstreamDataService } from '../../../../../../../../core/data/bitstream-data.service'; +import { RemoteData } from '../../../../../../../../core/data/remote-data'; +import { LayoutField } from '../../../../../../../../core/layout/models/box.model'; +import { + Bitstream, + ChecksumInfo, +} from '../../../../../../../../core/shared/bitstream.model'; +import { Item } from '../../../../../../../../core/shared/item.model'; +import { getFirstCompletedRemoteData } from '../../../../../../../../core/shared/operators'; +import { TruncatableComponent } from '../../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { FileSizePipe } from '../../../../../../../../shared/utils/file-size-pipe'; +import { ThemedThumbnailComponent } from '../../../../../../../../thumbnail/themed-thumbnail.component'; +import { BitstreamRenderingModelComponent } from '../../bitstream-rendering-model'; +import { AttachmentRenderComponent } from './attachment-render/attachment-render.component'; +import { AttachmentRenderingType } from './attachment-type.decorator'; + +@Component({ + selector: 'ds-bitstream-attachment', + templateUrl: './bitstream-attachment.component.html', + styleUrls: ['./bitstream-attachment.component.scss'], + imports: [ + AsyncPipe, + AttachmentRenderComponent, + FileSizePipe, + ThemedThumbnailComponent, + TitleCasePipe, + TranslateModule, + TruncatableComponent, + TruncatablePartComponent, + ], +}) +export class BitstreamAttachmentComponent extends BitstreamRenderingModelComponent implements OnInit { + + /** + * Environment variables configuring the fields to be viewed + */ + envMetadata = environment.layout.advancedAttachmentRendering.metadata; + + /** + * Configuration type enum + */ + AdvancedAttachmentElementType = AdvancedAttachmentElementType; + + /** + * Configuration type enum + */ + AttachmentRenderingType = AttachmentRenderingType; + + /** + * All item providers to show buttons of + */ + allAttachmentProviders: string[] = []; + + /** + * Attachment metadata to be displayed in title case + */ + attachmentTypeMetadata = 'dc.type'; + + /** + * Attachment to be displayed + */ + @Input() attachment: Bitstream; + + /** + * Format of the bitstream + */ + bitstreamFormat$: Observable; + + /** + * Size of the bitstream + */ + bitstreamSize: number; + /** + * Checksum info of the bitstream + */ + checksumInfo: ChecksumInfo; + + thumbnail$: BehaviorSubject> = new BehaviorSubject>(null); + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected readonly bitstreamDataService: BitstreamDataService, + protected readonly translateService: TranslateService, + protected readonly router: Router, + protected readonly route: ActivatedRoute, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, bitstreamDataService, translateService); + } + + ngOnInit() { + this.attachment.thumbnail.pipe( + getFirstCompletedRemoteData(), + ).subscribe((thumbnail: RemoteData) => { + this.thumbnail$.next(thumbnail); + }); + this.allAttachmentProviders = this.attachment?.allMetadataValues('bitstream.viewer.provider'); + this.bitstreamFormat$ = this.getFormat(this.attachment); + this.bitstreamSize = this.getSize(this.attachment); + this.checksumInfo = this.getChecksum(this.attachment); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.html new file mode 100644 index 00000000000..0e438b0352e --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.html @@ -0,0 +1,26 @@ +
+ @for (attachment of (bitstreams$ | async); track attachment) { +
+ + + @if (fileName(attachment)) { + @if (getType(attachment)) { + {{getType(attachment) | titlecase}}: + }{{fileName(attachment)}} ({{getSize(attachment) | dsFileSize}}) + } + + + @if (getDescription(attachment)) { + {{getDescription(attachment)}} + } + + +
+ } + @if (canViewMore) { + + } +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.spec.ts new file mode 100644 index 00000000000..3d50ccb0761 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.spec.ts @@ -0,0 +1,429 @@ +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { LocaleService } from '@dspace/core/locale/locale.service'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { attachmentsMock } from '@dspace/core/testing/attachments.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { + Observable, + of, +} from 'rxjs'; + +import { ThemedFileDownloadLinkComponent } from '../../../../../../../shared/file-download-link/themed-file-download-link.component'; +import { TruncatableComponent } from '../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { FileSizePipe } from '../../../../../../../shared/utils/file-size-pipe'; +import { FieldRenderingType } from '../field-rendering-type'; +import { AttachmentComponent } from './attachment.component'; + +describe('AttachmentComponent', () => { + let component: AttachmentComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let localeService: any; + + const languageList = ['en;q=1', 'it;q=0.9', 'de;q=0.8', 'fr;q=0.7']; + + const testItem = Object.assign(new Item(), { + bundles: of({}), + metadata: { + 'dc.identifier.doi': [ + { + value: 'doi:10.1392/dironix', + }, + ], + }, + _links: { + self: { href: 'obj-selflink' }, + }, + uuid: 'item-uuid', + }); + + const mockField: LayoutField = { + metadata: '', + label: 'Files', + rendering: FieldRenderingType.ATTACHMENT, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + bitstream: { + bundle: 'ORIGINAL', + metadataField: null, + metadataValue: null, + }, + }; + + const mockFieldWithMetadata: LayoutField = { + metadata: '', + label: 'Files', + rendering: FieldRenderingType.ATTACHMENT, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: 'main article', + }, + }; + + const mockFieldWithRegexMetadata: LayoutField = { + metadata: '', + label: 'Files', + rendering: FieldRenderingType.ATTACHMENT, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: '(/^Test Article/i)', + }, + }; + + const mockLocaleService = jasmine.createSpyObj('LocaleService', [ + 'getCurrentLanguageCode', + 'getLanguageCodeList', + ]); + + + const bitstream1 = Object.assign(new Bitstream(), { + id: 'bitstream4', + uuid: 'bitstream4', + metadata: { + 'dc.title': [ + { + value: 'test', + }, + ], + 'dc.type': [ + { + value: 'test', + }, + ], + 'dc.description': [ + { + value: 'test', + }, + ], + }, + _links: { + self: { href: 'obj-selflink' }, + }, + }); + const bitstream2 = Object.assign(new Bitstream(), { + id: 'bitstream4', + uuid: 'bitstream4', + metadata: { + }, + _links: { + self: { href: 'obj-selflink' }, + }, + }); + + const mockBitstreamDataService: any = jasmine.createSpyObj('BitstreamDataService', { + getThumbnailFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new Bitstream()); + }, + findAllByItemAndBundleName: jasmine.createSpy('findAllByItemAndBundleName'), + findByItem: jasmine.createSpy('findByItem'), + }); + + const mockAuthorizedService = jasmine.createSpyObj('AuthorizationDataService', { + isAuthorized: jasmine.createSpy('isAuthorized'), + }); + const getDefaultTestBedConf = () => { + return { + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + RouterTestingModule, + AttachmentComponent, + FileSizePipe, + ], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: AuthorizationDataService, useValue: mockAuthorizedService }, + { provide: LocaleService, useValue: mockLocaleService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }; + }; + + describe('when pagination is disabled', () => { + beforeEach(() => { + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AttachmentComponent, { + remove: { + imports: [ + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + mockAuthorizedService.isAuthorized.and.returnValues(of(true), of(true)); + component.envPagination.enabled = false; + mockBitstreamDataService.findAllByItemAndBundleName.and.returnValues(createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1]))); + mockBitstreamDataService.findByItem.and.returnValues(createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1]))); + localeService = TestBed.inject(LocaleService); + localeService.getCurrentLanguageCode.and.returnValue(of('en')); + localeService.getLanguageCodeList.and.returnValue(of(languageList)); + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList(attachmentsMock))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve bitstreams without pagination', fakeAsync(() => { + flush(); + expect(component.getBitstreamsByItem).toHaveBeenCalled(); + })); + + it('should not show view more button', fakeAsync(() => { + flush(); + expect(fixture.debugElement.query(By.css('a[data-test="view-more"]'))).toBeNull(); + })); + + it('check metadata rendering', fakeAsync(() => { + flush(); + const valueFound = fixture.debugElement.queryAll(By.css('ds-file-download-link')); + expect(valueFound.length).toBe(3); + })); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + + it('should show information properly', () => { + const titles = de.queryAll(By.css('[data-test="title"]')); + expect(titles.length).toBe(3); + expect(titles[0].nativeElement.innerHTML).toContain('test-unspecified.pdf (127.37 KB)'); + expect(titles[1].nativeElement.innerHTML).toContain('main.pdf (127.37 KB)'); + expect(titles[2].nativeElement.innerHTML).toContain('main-regex.pdf (127.37 KB)'); + }); + + describe('and the field has metadata key and value set as value', () => { + beforeEach(() => { + // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset + TestBed.resetTestingModule(); + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AttachmentComponent, { + remove: { + imports: [ + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + TestBed.overrideProvider('fieldProvider', { useValue: mockFieldWithMetadata }); + fixture = TestBed.createComponent(AttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([attachmentsMock[1]]))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should show main article attachment', () => { + expect(de.query(By.css('[data-test="title"]')).nativeElement.innerHTML).toContain('main.pdf (127.37 KB)'); + }); + + }); + + describe('when the field has metadata key and value set as regex', () => { + beforeEach(() => { + // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset + TestBed.resetTestingModule(); + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AttachmentComponent, { + remove: { + imports: [ + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + TestBed.overrideProvider('fieldProvider', { useValue: mockFieldWithRegexMetadata }); + fixture = TestBed.createComponent(AttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([attachmentsMock[2]]))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should show regex article attachment', () => { + expect(de.query(By.css('[data-test="title"]')).nativeElement.innerHTML).toContain('main-regex.pdf (127.37 KB)'); + }); + + }); + + describe('and attachment has no metadata', () => { + + beforeEach(() => { + // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset + TestBed.resetTestingModule(); + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AttachmentComponent, { + remove: { + imports: [ + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstream2]))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('Should not render title part', () => { + expect(fixture.debugElement.query(By.css('[data-test="title"]'))).toBeFalsy(); + }); + it('Should not render type part', () => { + expect(fixture.debugElement.query(By.css('[data-test="type"]'))).toBeFalsy(); + }); + it('Should not render description part', () => { + expect(fixture.debugElement.query(By.css('[data-test="description"]'))).toBeFalsy(); + }); + + }); + + describe('and attachment has dc.title,dc.type and dc.description', () => { + + beforeEach(() => { + // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset + TestBed.resetTestingModule(); + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AttachmentComponent, { + remove: { + imports: [ + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstream1]))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('Should render title part', () => { + expect(fixture.debugElement.query(By.css('[data-test="title"]'))).toBeTruthy(); + }); + it('Should render type part', () => { + expect(fixture.debugElement.query(By.css('[data-test="type"]'))).toBeTruthy(); + }); + it('Should render description part', () => { + expect(fixture.debugElement.query(By.css('[data-test="description"]'))).toBeTruthy(); + }); + }); + }); + + describe('when pagination is enabled', () => { + beforeEach(() => { + TestBed.configureTestingModule(getDefaultTestBedConf()) + .overrideComponent(AttachmentComponent, { + remove: { + imports: [ + TruncatableComponent, + ThemedFileDownloadLinkComponent, + TruncatablePartComponent, + ], + }, + }); + fixture = TestBed.createComponent(AttachmentComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + mockAuthorizedService.isAuthorized.and.returnValues(of(true), of(true)); + component.envPagination.enabled = true; + localeService = TestBed.inject(LocaleService); + localeService.getCurrentLanguageCode.and.returnValue(of('en')); + localeService.getLanguageCodeList.and.returnValue(of(languageList)); + let spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstream1, bitstream1]))); + component.item = testItem; + fixture.detectChanges(); + }); + + it('should show view more button', () => { + expect(fixture.debugElement.query(By.css('a[data-test="view-more"]'))).toBeTruthy(); + }); + + it('should show 2 elements', () => { + expect(fixture.debugElement.queryAll(By.css('ds-file-download-link')).length).toEqual(2); + }); + + it('and view more button is clicked it should show 4 elements', () => { + const btn = fixture.debugElement.query(By.css('a[data-test="view-more"]')); + (component.getBitstreamsByItem as any).and.returnValue(of(createPaginatedList([bitstream1, bitstream1]))); + fixture.detectChanges(); + btn.nativeElement.click(); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css('ds-file-download-link')).length).toEqual(4); + }); + + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.ts new file mode 100644 index 00000000000..4e7b0f95c85 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/attachment.component.ts @@ -0,0 +1,128 @@ +import { + AsyncPipe, + TitleCasePipe, +} from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { FindListOptions } from '@dspace/core/data/find-list-options.model'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../../../../../../environments/environment'; +import { ThemedFileDownloadLinkComponent } from '../../../../../../../shared/file-download-link/themed-file-download-link.component'; +import { TruncatableComponent } from '../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { FileSizePipe } from '../../../../../../../shared/utils/file-size-pipe'; +import { BitstreamAttachmentRenderingModelComponent } from './bitstream-attachment-rendering.model'; + +@Component({ + selector: 'ds-attachment', + templateUrl: './attachment.component.html', + styleUrls: ['./attachment.component.scss'], + imports: [ + AsyncPipe, + FileSizePipe, + ThemedFileDownloadLinkComponent, + TitleCasePipe, + TranslateModule, + TruncatableComponent, + TruncatablePartComponent, + ], +}) +/** + * The component for displaying a thumbnail rendered metadata box + */ +export class AttachmentComponent extends BitstreamAttachmentRenderingModelComponent implements OnInit { + + /** + * List of bitstreams to show + */ + bitstreams$: BehaviorSubject = new BehaviorSubject([]); + + /** + * If the list should show view more button + */ + canViewMore = false; + + /** + * Environment variables configuring pagination + */ + envPagination = environment.layout.advancedAttachmentRendering.pagination; + + /** + * Pagination configuration object + */ + pageOptions: FindListOptions; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected bitstreamDataService: BitstreamDataService, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, bitstreamDataService, translateService); + } + + /** + * On init check if we want to show the attachment list with pagination or show all attachments + */ + ngOnInit() { + this.initPageOptions(); + this.retrieveBitstreams(); + } + + /** + * Retrieve the list of bitstream to show + */ + retrieveBitstreams(): void { + this.getBitstreamsByItem(this.pageOptions).pipe( + map((bitstreamList: PaginatedList) => { + this.canViewMore = this.envPagination.enabled && this.pageOptions?.currentPage !== bitstreamList?.pageInfo?.totalPages; + return bitstreamList.page; + }), + take(1), + ).subscribe((bitstreams: Bitstream[]) => { + if (this.envPagination.enabled) { + this.bitstreams$.next([...this.bitstreams$.value, ...bitstreams]); + } else { + this.bitstreams$.next(bitstreams); + } + }); + } + + /** + * When view more is clicked show the next page and check if view more button should be shown + */ + viewMore() { + this.pageOptions.currentPage++; + this.retrieveBitstreams(); + } + + /** + * Init page option according to configuration + * @protected + */ + protected initPageOptions(): void { + this.pageOptions = Object.assign(new FindListOptions(), { + elementsPerPage: this.envPagination.enabled ? this.envPagination.elementsPerPage : 100, + currentPage: 1, + }); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/bitstream-attachment-rendering.model.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/bitstream-attachment-rendering.model.ts new file mode 100644 index 00000000000..fef792fa070 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/attachment/bitstream-attachment-rendering.model.ts @@ -0,0 +1,54 @@ +import { + Component, + Inject, +} from '@angular/core'; +import { + buildPaginatedList, + PaginatedList, +} from '@dspace/core/data/paginated-list.model'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { followLink } from '@dspace/core/shared/follow-link-config.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { BitstreamDataService } from '../../../../../../../core/data/bitstream-data.service'; +import { FindListOptions } from '../../../../../../../core/data/find-list-options.model'; +import { BitstreamRenderingModelComponent } from '../bitstream-rendering-model'; + +/** + * This class defines the basic model to extends for create a new + * bitstream-attachment field render component. + * This will render all bitstreams that aren't marked hidden. + */ +@Component({ + template: '', +}) +export abstract class BitstreamAttachmentRenderingModelComponent extends BitstreamRenderingModelComponent { + constructor( + @Inject('fieldProvider') fieldProvider: LayoutField, + @Inject('itemProvider') itemProvider: Item, + @Inject('renderingSubTypeProvider') renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + bitstreamDataService: BitstreamDataService, + translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, bitstreamDataService, translateService); + } + + getBitstreamsByItem(options?: FindListOptions): Observable> { + return this.bitstreamDataService + .findShowableBitstreamsByItem(this.item.uuid, this.field.bitstream.bundle, this.getMetadataFilters(), options, + false, false, followLink('thumbnail'), followLink('format')) + .pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData>) => { + return response.hasSucceeded ? response.payload : buildPaginatedList(null, []); + }), + ); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/bitstream-rendering-model.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/bitstream-rendering-model.ts new file mode 100644 index 00000000000..da1fc0fd376 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/bitstream-rendering-model.ts @@ -0,0 +1,181 @@ +import { + Component, + Inject, +} from '@angular/core'; +import { + BitstreamDataService, + MetadataFilter, +} from '@dspace/core/data/bitstream-data.service'; +import { FindListOptions } from '@dspace/core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '@dspace/core/data/paginated-list.model'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { + Bitstream, + ChecksumInfo, +} from '@dspace/core/shared/bitstream.model'; +import { BitstreamFormat } from '@dspace/core/shared/bitstream-format.model'; +import { followLink } from '@dspace/core/shared/follow-link-config.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { + hasValue, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { RenderingTypeStructuredModelComponent } from './rendering-type-structured.model'; + +/** + * This class defines the basic model to extends for create a new + * bitstream field render component + */ +@Component({ + template: '', +}) +export abstract class BitstreamRenderingModelComponent extends RenderingTypeStructuredModelComponent { + + private TITLE_METADATA = 'dc.title'; + private SOURCE_METADATA = 'dc.source'; + private TYPE_METADATA = 'dc.type'; + private DESCRIPTION_METADATA = 'dc.description'; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected bitstreamDataService: BitstreamDataService, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + /** + * Returns the list of original bitstreams according to BUNDLE configured in the rendering + * + * @param options The {@link FindListOptions} for the request + */ + getBitstreams(options?: FindListOptions): Observable> { + return this.bitstreamDataService + .findAllByItemAndBundleName(this.item, this.field.bitstream.bundle, options, false, false, followLink('thumbnail')) + .pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData>) => { + return response.hasSucceeded ? response.payload : buildPaginatedList(null, []); + }), + ); + } + + /** + * Returns the filename of given bitstream + * @param bitstream + */ + fileName(bitstream: Bitstream): string { + const title = bitstream.firstMetadataValue(this.TITLE_METADATA); + return hasValue(title) ? title : bitstream.firstMetadataValue(this.SOURCE_METADATA); + } + + /** + * Returns the size of given bitstreams in bytes + * @param bitstream + */ + getChecksum(bitstream: Bitstream): ChecksumInfo { + return bitstream.checkSum; + } + + /** + * Returns the size of given bitstreams in bytes + * @param bitstream + */ + getSize(bitstream: Bitstream): number { + return bitstream.sizeBytes; + } + + /** + * Returns type of given bistream + * @param bitstream + */ + getType(bitstream: Bitstream): string { + return bitstream.firstMetadataValue(this.TYPE_METADATA); + } + + /** + * Returns link of given bistream + * @param bitstream + */ + getLink(bitstream: Bitstream): string { + return bitstream._links.content.href; + } + + /** + * Returns format of given bistream + * @param bitstream + */ + getFormat(bitstream: Bitstream): Observable { + return bitstream.format?.pipe( + map((rd: RemoteData) => { + return rd.payload?.shortDescription; + }), + take(1), + ); + } + + /** + * Returns description of given bistream + * @param bitstream + */ + getDescription(bitstream: Bitstream): string { + return bitstream.firstMetadataValue(this.DESCRIPTION_METADATA); + } + + /** + * Returns the list of bitstreams according to BUNDLE configured in the rendering and filtering by the field configuration + * + * @param options The {@link FindListOptions} for the request + */ + getBitstreamsByItem(options?: FindListOptions): Observable> { + return this.bitstreamDataService + .findByItem( + this.item.uuid, + this.field.bitstream.bundle, + this.getMetadataFilters(), + options, + false, + false, + followLink('thumbnail'), + ) + .pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData>) => { + return response.hasSucceeded ? response.payload : buildPaginatedList(null, []); + }), + ); + } + + /** + * Composes the {@link MetadataFilter} array used as filter + * while retrieving bitstream from remote services. + * + * @protected + */ + protected getMetadataFilters(): MetadataFilter[] { + const filters: MetadataFilter[] = []; + if (isNotEmpty(this.field.bitstream.metadataValue)) { + filters.push({ + metadataName: this.field.bitstream.metadataField, + metadataValue: this.field.bitstream.metadataValue, + }); + } + return filters; + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.html new file mode 100644 index 00000000000..8133dcf3862 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.html @@ -0,0 +1,8 @@ + + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.spec.ts new file mode 100644 index 00000000000..efaff466388 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.spec.ts @@ -0,0 +1,59 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ItemPageCcLicenseFieldComponent } from '../../../../../../../item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; +import { CcLicenseLargeComponent } from './cc-license-large.component'; + +describe('CcLicenseLargeComponent', () => { + let component: CcLicenseLargeComponent; + let fixture: ComponentFixture; + + const mockItem = { + firstMetadataValue: jasmine.createSpy('firstMetadataValue').and.returnValue(''), + metadata: {}, + findMetadataSortedByPlace: jasmine.createSpy('findMetadataSortedByPlace').and.returnValue([]), + }; + + const mockField = { + metadataGroup: { elements: [] }, + styleValue: '', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CcLicenseLargeComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: mockItem }, + { provide: 'metadataValueProvider', useValue: {} }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }).overrideComponent(CcLicenseLargeComponent, { + remove: { + imports: [ItemPageCcLicenseFieldComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CcLicenseLargeComponent); + component = fixture.componentInstance; + + component.componentsToBeRenderedMap.set(0, [ + { field: { metadata: 'dc.rights' } as any, value: {} as any }, + { field: { metadata: 'dc.rights.uri' } as any, value: {} as any }, + ] as any); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.ts new file mode 100644 index 00000000000..e33df9bab4f --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-large/cc-license-large.component.ts @@ -0,0 +1,41 @@ +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { LayoutField } from '../../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { ItemPageCcLicenseFieldComponent } from '../../../../../../../item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; +import { MetadataGroupComponent } from '../metadataGroup/metadata-group.component'; + +@Component({ + selector: 'ds-cc-license-large', + imports: [ + ItemPageCcLicenseFieldComponent, + ], + templateUrl: './cc-license-large.component.html', + styleUrl: './cc-license-large.component.scss', +}) +export class CcLicenseLargeComponent extends MetadataGroupComponent implements OnInit { + + dcRights: any; + dcRightsUri: any; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + ngOnInit(): void { + super.ngOnInit(); + const ccLicenseEntryMetadata = this.componentsToBeRenderedMap.get(0); + [this.dcRights, this.dcRightsUri] = ccLicenseEntryMetadata.map((entryMeta) => entryMeta.field.metadata); + + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.html new file mode 100644 index 00000000000..8d61cf9a957 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.html @@ -0,0 +1,8 @@ + + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.spec.ts new file mode 100644 index 00000000000..1170c29ea35 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.spec.ts @@ -0,0 +1,60 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ItemPageCcLicenseFieldComponent } from '../../../../../../../item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; +import { CcLicenseSmallComponent } from './cc-license-small.component'; + +describe('CcLicenseSmallComponent', () => { + let component: CcLicenseSmallComponent; + let fixture: ComponentFixture; + + const mockItem = { + firstMetadataValue: jasmine.createSpy('firstMetadataValue').and.returnValue(''), + metadata: {}, + findMetadataSortedByPlace: jasmine.createSpy('findMetadataSortedByPlace').and.returnValue([]), + }; + + const mockField = { + metadataGroup: { elements: [] }, + styleValue: '', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CcLicenseSmallComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: mockItem }, + { provide: 'metadataValueProvider', useValue: {} }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }).overrideComponent(CcLicenseSmallComponent, { + remove: { + imports: [ItemPageCcLicenseFieldComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CcLicenseSmallComponent); + component = fixture.componentInstance; + + component.componentsToBeRenderedMap.set(0, [ + { field: { metadata: 'dc.rights' } as any, value: {} as any }, + { field: { metadata: 'dc.rights.uri' } as any, value: {} as any }, + ] as any); + + fixture.detectChanges(); + + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.ts new file mode 100644 index 00000000000..9d5288350b1 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/cc-license-small/cc-license-small.component.ts @@ -0,0 +1,41 @@ +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { LayoutField } from '../../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { ItemPageCcLicenseFieldComponent } from '../../../../../../../item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; +import { MetadataGroupComponent } from '../metadataGroup/metadata-group.component'; + +@Component({ + selector: 'ds-cc-license-small', + imports: [ + ItemPageCcLicenseFieldComponent, + ], + templateUrl: './cc-license-small.component.html', + styleUrl: './cc-license-small.component.scss', +}) +export class CcLicenseSmallComponent extends MetadataGroupComponent implements OnInit { + + dcRights: any; + dcRightsUri: any; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + ngOnInit(): void { + super.ngOnInit(); + const ccLicenseEntryMetadata = this.componentsToBeRenderedMap.get(0); + [this.dcRights, this.dcRightsUri] = ccLicenseEntryMetadata.map((entryMeta) => entryMeta.field.metadata); + + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.html new file mode 100644 index 00000000000..82931e02e11 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.html @@ -0,0 +1,5 @@ +
+ + {{ metadataValue.value | dsDate }} + +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.spec.ts new file mode 100644 index 00000000000..ceea10c16b0 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.spec.ts @@ -0,0 +1,106 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { LocaleService } from '@dspace/core/locale/locale.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { FieldRenderingType } from '../field-rendering-type'; +import { DateComponent } from './date.component'; + +describe('DateComponent', () => { + let component: DateComponent; + let fixture: ComponentFixture; + let localeService: LocaleService; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': '2020-08-24', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.title': [metadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + const localeServiceMock = Object.assign({ + getCurrentLanguageCode: () => of('en'), + }); + + const mockField: LayoutField = { + 'metadata': 'dc.date', + 'label': 'Date', + 'rendering': FieldRenderingType.DATE, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, DateComponent, DsDatePipe], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: LocaleService, useValue: localeServiceMock }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DateComponent); + component = fixture.componentInstance; + localeService = TestBed.inject(LocaleService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + expect(spanValueFound[0].nativeElement.textContent).toContain('August 24, 2020'); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.ts new file mode 100644 index 00000000000..61abf00e26e --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/date/date.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; + +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the date metadata fields + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-date]', + templateUrl: './date.component.html', + styleUrls: ['./date.component.scss'], + imports: [ + DsDatePipe, + ], +}) +export class DateComponent extends RenderingTypeValueModelComponent { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.html new file mode 100644 index 00000000000..af4f8522cf7 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.html @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.scss new file mode 100644 index 00000000000..b92a52cd35d --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.scss @@ -0,0 +1,4 @@ +.orcid-icon { + height: 1.2rem; + padding-left: 0.3rem; +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.spec.ts new file mode 100644 index 00000000000..aca0d692519 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.spec.ts @@ -0,0 +1,140 @@ +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ItemDataService } from '@dspace/core/data/item-data.service'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { MetadataLinkViewComponent } from '../../../../../../../shared/metadata-link-view/metadata-link-view.component'; +import { FieldRenderingType } from '../field-rendering-type'; +import { DynamicrefComponent } from './dynamicref.component'; + +describe('DynamicrefComponent', () => { + let component: DynamicrefComponent; + let fixture: ComponentFixture; + + const itemService = jasmine.createSpyObj('ItemDataService', { + findByIdWithProjections: jasmine.createSpy('findByIdWithProjections'), + }); + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'test item title', + 'language': null, + 'authority': '1', + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.author': [metadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + const testPerson = Object.assign(new Item(), { + id: '1', + bundles: of({}), + metadata: { + 'dspace.entity.type': [ + { + value: 'Person', + }, + ], + 'person.orgunit.id': [ + { + value: 'OrgUnit', + authority: '2', + }, + ], + 'person.identifier.orcid': [ + { + language: 'en_US', + value: '0000-0001-8918-3592', + }, + ], + 'dspace.orcid.authenticated': [ + { + language: null, + value: 'authenticated', + }, + ], + }, + }); + + const mockField: LayoutField = { + 'metadata': 'dc.title', + 'label': 'Title', + 'rendering': FieldRenderingType.DYNAMICREF, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, DynamicrefComponent, MetadataLinkViewComponent], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: ItemDataService, useValue: itemService }, + ], + }) + .overrideComponent(DynamicrefComponent, { remove: { imports: [MetadataLinkViewComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicrefComponent); + component = fixture.componentInstance; + itemService.findByIdWithProjections.and.returnValue(createSuccessfulRemoteDataObject$(testPerson)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', fakeAsync(() => { + flush(); + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('ds-metadata-link-view')); + expect(valueFound.length).toBe(1); + })); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + +}); + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.ts new file mode 100644 index 00000000000..280976c6d8c --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/dynamicref/dynamicref.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; + +import { MetadataLinkViewComponent } from '../../../../../../../shared/metadata-link-view/metadata-link-view.component'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the dynamicref metadata fields + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-dynamicref]', + templateUrl: './dynamicref.component.html', + styleUrls: ['./dynamicref.component.scss'], + imports: [ + MetadataLinkViewComponent, + ], +}) +export class DynamicrefComponent extends RenderingTypeValueModelComponent { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/field-rendering-type.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/field-rendering-type.ts new file mode 100644 index 00000000000..deb286244d2 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/field-rendering-type.ts @@ -0,0 +1,24 @@ +export enum FieldRenderingType { + TEXT = 'TEXT', + HEADING = 'HEADING', + LONGTEXT = 'LONGTEXT', + DATE = 'DATE', + LINK = 'LINK', + IDENTIFIER = 'IDENTIFIER', + DYNAMICREF = 'DYNAMICREF', + THUMBNAIL = 'THUMBNAIL', + ATTACHMENT = 'ATTACHMENT', + TABLE = 'TABLE', + INLINE = 'INLINE', + ORCID = 'ORCID', + TAG = 'TAG', + VALUEPAIR = 'VALUEPAIR', + HTML = 'HTML', + LONGHTML = 'LONGHTML', + ADVANCEDATTACHMENT = 'ADVANCEDATTACHMENT', + SIMPLEATTACHMENT = 'SIMPLEATTACHMENT', + AUTHORITYLINK = 'AUTHORITYLINK', + CCLICENSEFULL = 'CCLICENSEFULL', + CCLICENSE = 'CCLICENSE', + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.html new file mode 100644 index 00000000000..7c6e4770c87 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.html @@ -0,0 +1,4 @@ + + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.spec.ts new file mode 100644 index 00000000000..5601a792d19 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.spec.ts @@ -0,0 +1,49 @@ +/* +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HeadingComponent } from './heading.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../../../../../shared/mocks/translate-loader.mock'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { By } from '@angular/platform-browser'; +import { medataBoxConfigurationMock } from 'src/app/shared/testing/box-configurations.mock'; + +class TestItem { + allMetadataValues(key: string): string[] { + return ['This is the title']; + } +} + +describe('HeadingComponent', () => { + let component: HeadingComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), BrowserAnimationsModule], + declarations: [HeadingComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HeadingComponent); + component = fixture.componentInstance; + component.item = new TestItem() as Item; + component.field = medataBoxConfigurationMock.rows[0].fields[0]; + fixture.detectChanges(); + }); + + xit('check heading rendering', () => { + const divFound = fixture.debugElement.queryAll(By.css('div.h2')); + expect(divFound.length).toBe(1); + expect(divFound[0].nativeElement.textContent).toContain((new TestItem()).allMetadataValues('')[0]); + }); +}); +*/ diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.ts new file mode 100644 index 00000000000..6d6b742407d --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/heading/heading.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +import { ComcolPageHeaderComponent } from '../../../../../../../shared/comcol/comcol-page-header/comcol-page-header.component'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the heading metadata fields + */ +@Component({ + selector: 'ds-heading-row', + templateUrl: './heading.component.html', + styleUrls: ['./heading.component.scss'], + imports: [ + ComcolPageHeaderComponent, + ], +}) +export class HeadingComponent extends RenderingTypeValueModelComponent { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.html new file mode 100644 index 00000000000..d239f102836 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.spec.ts new file mode 100644 index 00000000000..6f8b52583f0 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.spec.ts @@ -0,0 +1,103 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { HtmlComponent } from './html.component'; + +describe('HtmlComponent', () => { + let component: HtmlComponent; + let fixture: ComponentFixture; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'test item title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.title': [metadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + + const mockField: LayoutField = { + 'metadata': 'dc.title', + 'label': 'Title', + 'rendering': 'html', + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + HtmlComponent, + DsDatePipe, + ], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + ], + declarations: [], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HtmlComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + expect(spanValueFound[0].nativeElement.textContent).toContain(metadataValue.value); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.ts new file mode 100644 index 00000000000..c412fb043bf --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/html/html.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; + +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the text metadata fields + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-html]', + templateUrl: './html.component.html', + styleUrls: ['./html.component.scss'], +}) +export class HtmlComponent extends RenderingTypeValueModelComponent { + + /** + * If the metadata value does not contain HTML tags then replace newline character with
+ * @param text + */ + processHtml(text: string): string { + const htmlTagRegex = /<.*?>/; + return htmlTagRegex.test(text) + ? text.replace(/\n/g, '
') + : text; + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.html new file mode 100644 index 00000000000..ff164876374 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.html @@ -0,0 +1,12 @@ +
+
+ @if (subTypeIcon && iconPosition !== iconPositionEnum.NONE) { + + source-icon + + } + + {{ identifier?.text }} + +
+
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.scss new file mode 100644 index 00000000000..f1496a808b2 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.scss @@ -0,0 +1,20 @@ +.source-icon { + height: var(--ds-identifier-sybetype-icon-height); + min-height: 16px; + width: auto; +} + +.identifier-container-LEFT { + flex-direction: row; + .source-icon { + margin-right: 0.2em; + } +} + +.identifier-container-RIGHT { + flex-direction: row-reverse; + justify-content: flex-end; + .source-icon { + margin-left: 0.2em; + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.spec.ts new file mode 100644 index 00000000000..6a555a48abf --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.spec.ts @@ -0,0 +1,333 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { environment } from '../../../../../../../../environments/environment'; +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { ResolverStrategyService } from '../../../../../../services/resolver-strategy.service'; +import { FieldRenderingType } from '../field-rendering-type'; +import { IdentifierComponent } from './identifier.component'; + +describe('IdentifierComponent', () => { + let component: IdentifierComponent; + let fixture: ComponentFixture; + let service: ResolverStrategyService; + + const doiMetadataValueWithoutSubType = Object.assign(new MetadataValue(), { + 'value': 'doi:10.1392/dironix', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + const doiMetadataValueWithSubType = Object.assign(new MetadataValue(), { + 'value': '10.1392/dironix', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + const hdlMetadataValue = Object.assign(new MetadataValue(), { + 'value': 'hdl:2434/690937', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + const emailMetadataValue = Object.assign(new MetadataValue(), { + 'value': 'mailto:danilo.dinuzzo@4science.it', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.identifier.doi': [doiMetadataValueWithoutSubType], + 'dc.identifier.hdl': [hdlMetadataValue], + 'person.email': [emailMetadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + const mockField: LayoutField = { + 'metadata': 'dc.identifier', + 'label': 'Identifier', + 'rendering': FieldRenderingType.IDENTIFIER, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, IdentifierComponent], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: doiMetadataValueWithoutSubType }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: ResolverStrategyService, useClass: ResolverStrategyService }, + { provide: APP_CONFIG, useValue: environment }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IdentifierComponent); + component = fixture.componentInstance; + service = TestBed.inject(ResolverStrategyService); + }); + + describe('doi identifier rendering', () => { + describe('without sub-type', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('a')); + expect(valueFound.length).toBe(1); + + const expectedContent = doiMetadataValueWithoutSubType.value.replace('doi:', ''); + const expectedHref = service.getBaseUrl('doi') + expectedContent; + expect(valueFound[0].nativeElement.textContent).toBe(expectedContent); + expect(valueFound[0].nativeElement.href).toBe(expectedHref); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + }); + + describe('with sub-type', () => { + beforeEach(() => { + component.metadataValue = doiMetadataValueWithSubType; + component.renderingSubType = 'doi'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('a')); + expect(valueFound.length).toBe(1); + + const expectedContent = doiMetadataValueWithSubType.value.replace('doi:', ''); + const expectedHref = service.getBaseUrl('doi') + expectedContent; + expect(valueFound[0].nativeElement.textContent).toBe(expectedContent); + expect(valueFound[0].nativeElement.href).toBe(expectedHref); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + }); + }); + + describe('hdl identifier rendering', () => { + beforeEach(() => { + component.metadataValue = hdlMetadataValue; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('a')); + expect(valueFound.length).toBe(1); + + const expectedContent = hdlMetadataValue.value.replace('hdl:', ''); + const expectedHref = service.getBaseUrl('hdl') + expectedContent; + expect(valueFound[0].nativeElement.textContent).toBe(expectedContent); + expect(valueFound[0].nativeElement.href).toBe(expectedHref); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + }); + + describe('email identifier rendering', () => { + beforeEach(() => { + component.metadataValue = emailMetadataValue; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('a')); + expect(valueFound.length).toBe(1); + + const expectedContent = emailMetadataValue.value.replace('mailto:', ''); + const expectedHref = service.getBaseUrl('mailto') + expectedContent; + expect(valueFound[0].nativeElement.textContent).toBe(expectedContent); + expect(valueFound[0].nativeElement.href).toBe(expectedHref); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + }); + + it('should keep white space in metadata value if shouldKeepWhiteSpaces is true', () => { + expect(component.composeLink('keep my white spaces', 'keepMyWhiteSpaces')).toEqual({ + href: 'https://keepmywhitespaces.com/keep my white spaces', + text: 'keep my white spaces', + }); + }); + + it('should not keep white space in metadata value if shouldKeepWhiteSpaces is false', () => { + expect(component.composeLink('do not keep my white spaces', 'doi')).toEqual({ + href: 'https://doi.org/donotkeepmywhitespaces', + text: 'do not keep my white spaces', + }); + }); + + describe('validateLink method', () => { + describe('with http/https URLs', () => { + it('should validate http URL', () => { + const link = 'http://example.com'; + // Access private method through component instance (for testing purposes) + expect((component as any).validateLink(link)).toBe(true); + }); + + it('should validate https URL', () => { + const link = 'https://example.com'; + expect((component as any).validateLink(link)).toBe(true); + }); + + it('should validate https URL with path', () => { + const link = 'https://example.com/path/to/resource'; + expect((component as any).validateLink(link)).toBe(true); + }); + + it('should reject invalid URL with spaces', () => { + const link = 'https://example.com/path with spaces'; + expect((component as any).validateLink(link)).toBe(false); + }); + + it('should reject invalid URL with quotes', () => { + const link = 'https://example.com/path"quoted"'; + expect((component as any).validateLink(link)).toBe(false); + }); + }); + + describe('with subtypeValue.link prefix', () => { + beforeEach(() => { + component.renderingSubType = 'ror'; + fixture.detectChanges(); + }); + + it('should validate link that already starts with subtypeValue.link', () => { + const link = 'https://ror.org/123abc'; + expect((component as any).validateLink(link)).toBe(true); + }); + + it('should validate link that is exactly subtypeValue.link', () => { + const link = 'https://ror.org'; + expect((component as any).validateLink(link)).toBe(true); + }); + + it('should reject link that does not start with subtypeValue.link', () => { + const link = '123abc'; + expect((component as any).validateLink(link)).toBe(false); + }); + + it('should not validate partial match of subtypeValue.link', () => { + const link = 'ror.example.com'; + expect((component as any).validateLink(link)).toBe(false); + }); + }); + + describe('composeLink with validateLink', () => { + beforeEach(() => { + component.renderingSubType = 'ror'; + fixture.detectChanges(); + }); + + it('should not prepend subtypeValue.link if link already has it', () => { + const link = 'https://ror.org/123abc'; + const result = component.composeLink(link, 'ror'); + expect(result.href).toBe('https://ror.org/123abc'); + expect(result.text).toBe('https://ror.org/123abc'); + }); + + it('should prepend subtypeValue.link if link does not have it', () => { + const link = '123abc'; + const result = component.composeLink(link, 'ror'); + expect(result.href).toBe('https://ror.org/123abc'); + expect(result.text).toBe('123abc'); + }); + + it('should handle complete URLs with different domain', () => { + const link = 'https://example.com/123abc'; + const result = component.composeLink(link, 'ror'); + expect(result.href).toBe('https://example.com/123abc'); + expect(result.text).toBe('https://example.com/123abc'); + }); + }); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.ts new file mode 100644 index 00000000000..d2f36c7322d --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/identifier/identifier.component.ts @@ -0,0 +1,206 @@ + +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { + IdentifierSubtypesConfig, + IdentifierSubtypesIconPositionEnum, +} from '@dspace/config/identifier-subtypes-config.interface'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { environment } from 'src/environments/environment'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { MetadataLinkValue } from '../../../../../../models/dynamic-layout-metadata-link-value.model'; +import { ResolverStrategyService } from '../../../../../../services/resolver-strategy.service'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the identifier metadata fields. + */ +@Component({ + selector: 'ds-identifier', + templateUrl: './identifier.component.html', + styleUrls: ['./identifier.component.scss'], + imports: [ + NgbTooltipModule, + ], +}) +export class IdentifierComponent extends RenderingTypeValueModelComponent implements OnInit { + + /** + * The identifier to render + */ + identifier: MetadataLinkValue; + /** + * value of href anchor + */ + href: string[]; + /** + * text to show in the anchor + */ + text: string[]; + /** + * specifies where to open the linked document + */ + target = '_blank'; + + /** + * The identifier subtype configurations + */ + identifierSubtypeConfig: IdentifierSubtypesConfig[] = environment.item.metadataLinkViewPopoverData.identifierSubtypes; + + /** + * The icon to display for the identifier subtype + */ + subTypeIcon: string; + + /** + * The position of the icon relative to the identifier + */ + iconPosition: IdentifierSubtypesIconPositionEnum = IdentifierSubtypesIconPositionEnum.NONE; + + /** + * The link to navigate to when the icon is clicked + */ + iconLink = ''; + + /** + * The identifier subtype to render + */ + iconPositionEnum = IdentifierSubtypesIconPositionEnum; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('metadataValueProvider') public metadataValueProvider: MetadataValue, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected resolver: ResolverStrategyService, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, metadataValueProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + getIdentifierFromValue() { + let identifier: MetadataLinkValue; + if (isNotEmpty(this.renderingSubType)) { + identifier = this.composeLink(this.metadataValue.value, this.renderingSubType); + } else { + // Check if the value is a link (http, https, ftp or ftps) + // otherwise resolve link with managed urn + if (this.resolver.checkLink(this.metadataValue.value)) { + identifier = { + href: this.metadataValue.value, + text: this.metadataValue.value, + }; + } else { + for (const urn of this.resolver.managedUrn) { + if (hasValue(this.metadataValue.value) && this.metadataValue.value.toLowerCase().startsWith(urn)) { + identifier = this.composeLink(this.metadataValue.value, urn); + break; + } + } + if (!identifier) { + identifier = { + href: this.metadataValue.value, + text: this.metadataValue.value, + }; + } + } + } + + return identifier; + } + + /** + * Create a MetadataLinkValue object with the given href and text + * @param href the href value + * @param text the text value + * @returns MetadataLinkValue object + */ + private createMetadataLinkValue(href: string, text: string): MetadataLinkValue { + text = text.trim() !== '' ? text : href; + return { href, text }; + } + + /** + * Set href and text of the component based on urn + * and the given metadata value. + * Is handling the case when the urn is configured in the default-app-config + * and the link is pre-configured. + * @param metadataValue the metadata value + * @param urn URN type (doi, hdl, mailto) + */ + composeLink(metadataValue: string, urn: string): MetadataLinkValue { + const subtypeValue = this.getIdentifierSubtypeValue(); + + if (hasValue(subtypeValue)) { + const href = this.validateLink(metadataValue) ? metadataValue : `${subtypeValue.link}/${metadataValue}`; + return this.createMetadataLinkValue(href, metadataValue); + } + + let value = metadataValue; + const rep = `${urn}:`; + if (metadataValue.startsWith(rep)) { + value = metadataValue.replace(rep, ''); + } + const shouldKeepWhiteSpaces = environment.layout + .urn?.find((urnConfig) => urnConfig.name === urn)?.shouldKeepWhiteSpaces; + const href = this.resolver.getBaseUrl(urn) + (shouldKeepWhiteSpaces ? value : value.replace(/\s/g, '')); + return this.createMetadataLinkValue(href, value); + } + + ngOnInit(): void { + this.identifier = this.getIdentifierFromValue(); + this.setIconDetails(); + } + + /** + * Sets the icon details based on the identifier subtype configuration. + * If the identifier subtype is not empty, it searches for the subtype with a matching name to the rendering subtype. + * If a matching subtype is found, it sets the icon position, subtype icon, and icon link based on the subtype's properties. + */ + private setIconDetails() { + const subtypeVal = this.getIdentifierSubtypeValue(); + if (hasNoValue(subtypeVal)) { + return; + } + this.iconPosition = subtypeVal.iconPosition; + this.subTypeIcon = subtypeVal.iconPosition !== IdentifierSubtypesIconPositionEnum.NONE ? subtypeVal?.icon : ''; + this.iconLink = subtypeVal?.link; + } + + /** + * Retrieves the value of the identifier subtype configuration based on the rendering subtype. + * @returns The identifier subtype configuration object. + */ + private getIdentifierSubtypeValue(): IdentifierSubtypesConfig { + if (isNotEmpty(this.identifierSubtypeConfig)) { + const subtypeVal = this.identifierSubtypeConfig.find((subtype) => subtype.name === this.renderingSubType); + return subtypeVal; + } + } + + /** + * Check if the given link is valid + * @param link the link to check + * @returns true if the link is valid, false otherwise + */ + private validateLink(link: string): boolean { + const urlRegex = /^(http|https):\/\/[^ "]+$/; + const subtypeValue = this.getIdentifierSubtypeValue(); + return urlRegex.test(link) || (hasValue(subtypeValue) && link.startsWith(subtypeValue.link)); + } +} + + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.html new file mode 100644 index 00000000000..daf9ed0ce22 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.ts new file mode 100644 index 00000000000..669dbbf10e2 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link-authority/link-authority.component.ts @@ -0,0 +1,87 @@ +import { NgClass } from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { isEmpty } from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { MetadataLinkValue } from '../../../../../../models/dynamic-layout-metadata-link-value.model'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the links metadata fields. + * The metadata value is used for href and text + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-link-authority]', + templateUrl: './link-authority.component.html', + styleUrls: ['./link-authority.component.scss'], + imports: [ + NgClass, + ], +}) +export class LinkAuthorityComponent extends RenderingTypeValueModelComponent implements OnInit { + + /** + * The link to render + */ + link: MetadataLinkValue; + iconStyle: string; + + defaultIcon = 'fa fa-external-link-square'; + + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('metadataValueProvider') public metadataValueProvider: MetadataValue, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, metadataValueProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + ngOnInit(): void { + this.link = this.getLinkFromValue(); + this.iconStyle = this.getWebsiteIcon(); + } + + /** + * Get the link value to render + */ + getLinkFromValue(): MetadataLinkValue { + return { + href: this.metadataValue.authority, + text: this.metadataValue.value, + }; + } + + getWebsiteIcon(): string { + let iconStyle = ''; + const siteUrl = this.metadataValue.authority; + if (isEmpty(siteUrl)) { + return iconStyle; + } + + const hostname = new URL(siteUrl)?.hostname?.replace(/^www\./, '') || + siteUrl.toLowerCase(); + + if (/^(linkedin\.com|lnkd\.in)$/.test(hostname)) { + iconStyle = 'fab fa-linkedin'; + } else if (/^(twitter\.com|x\.com|t\.co)$/.test(hostname)) { + iconStyle = 'fa-brands fa-x-twitter'; + } else if (/^(instagram\.com|instagr\.am)$/.test(hostname)) { + iconStyle = 'fab fa-instagram'; + } else if (/^(facebook\.com|fb\.com)$/.test(hostname)) { + iconStyle = 'fab fa-facebook'; + } + return iconStyle; + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.html new file mode 100644 index 00000000000..87862562d80 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.html @@ -0,0 +1,9 @@ +
+ @if (isLink) { + + {{ link.text }} + + } @else { + {{ link.text }} + } +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.spec.ts new file mode 100644 index 00000000000..17f707c1201 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.spec.ts @@ -0,0 +1,284 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { LinkComponent } from './link.component'; + +describe('LinkComponent', () => { + let component: LinkComponent; + let fixture: ComponentFixture; + let translateService; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'http://rest.api/item/link/id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.link': [metadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + const mockField: LayoutField = { + 'metadata': 'dc.title', + 'label': 'Title', + 'rendering': null, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + const i18nLabel = 'Default Label'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, LinkComponent], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LinkComponent); + component = fixture.componentInstance; + translateService = TestBed.inject(TranslateService); + }); + + describe('without sub-type', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.link-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('a')); + expect(valueFound.length).toBe(1); + + expect(valueFound[0].nativeElement.textContent).toContain(metadataValue.value); + expect(valueFound[0].nativeElement.href).toBe(metadataValue.value); + done(); + }); + + it('check value style', (done) => { + const valueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(valueFound.length).toBe(1); + done(); + }); + }); + + describe('with sub-type label', () => { + beforeEach(() => { + component.renderingSubType = 'LABEL'; + component.metadataValueProvider = Object.assign(new MetadataValue(), metadataValue, { + value: '[Default Label](http://rest.api/item/link/id)', + }); + component.metadataValue = component.metadataValueProvider; + spyOn(translateService, 'instant').and.returnValue(i18nLabel); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.link-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('a')); + expect(valueFound.length).toBe(1); + + expect(valueFound[0].nativeElement.textContent).toContain(i18nLabel); + expect(valueFound[0].nativeElement.href).toBe(metadataValue.value); + done(); + }); + + it('check value style', (done) => { + const valueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(valueFound.length).toBe(1); + done(); + }); + }); + + describe('with sub-type email', () => { + beforeEach(() => { + component.renderingSubType = 'EMAIL'; + spyOn(translateService, 'instant').and.returnValue(i18nLabel); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.link-value')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('a')); + expect(valueFound.length).toBe(1); + + expect(valueFound[0].nativeElement.textContent).toContain(metadataValue.value); + expect(valueFound[0].nativeElement.href).toBe('mailto:' + metadataValue.value); + done(); + }); + + it('check value style', (done) => { + const valueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(valueFound.length).toBe(1); + done(); + }); + }); + + describe('URL validation and conditional rendering', () => { + + describe('valid URLs', () => { + it('should render link for URL with http protocol', () => { + component.metadataValue = { value: 'http://test.com' } as MetadataValue; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isLink).toBe(true); + const linkElement = fixture.debugElement.query(By.css('span.link-value a')); + expect(linkElement).toBeTruthy(); + expect(linkElement.nativeElement.href).toBe('http://test.com/'); + }); + + it('should render link for URL with https protocol', () => { + component.metadataValue = { value: 'https://test.com' } as MetadataValue; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isLink).toBe(true); + const linkElement = fixture.debugElement.query(By.css('span.link-value a')); + expect(linkElement).toBeTruthy(); + }); + + it('should render link for URL with www prefix', () => { + component.metadataValue = { value: 'www.test.com' } as MetadataValue; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isLink).toBe(true); + const linkElement = fixture.debugElement.query(By.css('span.link-value a')); + expect(linkElement).toBeTruthy(); + expect(linkElement.nativeElement.href).toBe('http://www.test.com/'); + }); + + it('should render link for URL with path', () => { + component.metadataValue = { value: 'https://test.com/path/to/page' } as MetadataValue; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isLink).toBe(true); + const linkElement = fixture.debugElement.query(By.css('span.link-value a')); + expect(linkElement).toBeTruthy(); + }); + + it('should render link for FTP URL', () => { + component.metadataValue = { value: 'ftp://files.test.com' } as MetadataValue; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isLink).toBe(true); + const linkElement = fixture.debugElement.query(By.css('span.link-value a')); + expect(linkElement).toBeTruthy(); + expect(linkElement.nativeElement.href).toBe('ftp://files.test.com/'); + }); + }); + + describe('invalid URLs - plain text rendering', () => { + it('should render plain text for random text without URL pattern', () => { + component.metadataValue = { value: 'just some text' } as MetadataValue; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isLink).toBe(false); + const plainText = fixture.debugElement.query(By.css('span.plain-text-value')); + const link = fixture.debugElement.query(By.css('span.link-value a')); + + expect(plainText).toBeTruthy(); + expect(link).toBeFalsy(); + expect(plainText.nativeElement.textContent).toContain('just some text'); + }); + }); + }); + + describe('parseLabelValue', () => { + + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should correctly extract label and URL from [Label](URL) format', () => { + const input = '[My Label](https://example.com/path)'; + const result = component.parseLabelValue(input); + + expect(result).toEqual({ + label: 'My Label', + value: 'https://example.com/path', + }); + }); + + it('should return the same value if input does not match [Label](URL) format', () => { + const input = 'Just a plain URL'; + const result = component.parseLabelValue(input); + + expect(result).toEqual({ + label: input, + value: input, + }); + }); + + it('should handle empty string gracefully', () => { + const input = ''; + const result = component.parseLabelValue(input); + + expect(result).toEqual({ + label: '', + value: '', + }); + }); + + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.ts new file mode 100644 index 00000000000..5d52615a4c8 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/link/link.component.ts @@ -0,0 +1,138 @@ +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { MetadataLinkValue } from '../../../../../../models/dynamic-layout-metadata-link-value.model'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * Defines the list of subtypes for this rendering + */ +enum TYPES { + LABEL = 'LABEL', + EMAIL = 'EMAIL' +} + +/** + * This component renders the links metadata fields. + * The metadata value is used for href and text + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-link]', + templateUrl: './link.component.html', + styleUrls: ['./link.component.scss'], +}) +export class LinkComponent extends RenderingTypeValueModelComponent implements OnInit { + + /** + * The link to render + */ + link: MetadataLinkValue; + + /** + * Flag to determine if the value is a valid URL + */ + isLink = false; + + isEmail = false; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('metadataValueProvider') public metadataValueProvider: MetadataValue, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, metadataValueProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + ngOnInit(): void { + this.isLink = this.isValidUrl(); + this.link = this.getLinkFromValue(); + } + + /** + * Check if the metadata value is a valid URL + */ + isValidUrl(): boolean { + let value = this.metadataValue.value.trim(); + + // If the subtype is LABEL, the value is in [Label](URL) format — extract the URL part + if (hasValue(this.renderingSubType) && this.renderingSubType.toUpperCase() === TYPES.LABEL.toString()) { + const parsed = this.parseLabelValue(value); + value = parsed.value; + } + // Comprehensive URL regex that matches: + // - URLs with protocols (http, https, ftp, mailto, etc.) + // - URLs without protocols (www.example.com, example.com) + // - URLs with paths, query strings, and fragments + const urlRegex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/; + + return urlRegex.test(value); + } + + /** + * Get the link value to render + */ + getLinkFromValue(): MetadataLinkValue { + // If the component has label subtype get the text from translate service + let linkText: string; + let metadataValue: string; + + if (hasValue(this.renderingSubType) && this.renderingSubType.toUpperCase() === TYPES.EMAIL.toString()) { + this.isEmail = true; + metadataValue = 'mailto:' + this.metadataValue.value; + linkText = (hasValue(this.renderingSubType) && + this.renderingSubType.toUpperCase() === TYPES.EMAIL.toString()) ? this.metadataValue.value : this.translateService.instant(this.field.label); + } else if ((hasValue(this.renderingSubType) && this.renderingSubType.toUpperCase() === TYPES.LABEL.toString())) { + // Parse value in format [Label](URL) + const parsedValue = this.parseLabelValue(this.metadataValue.value); + + metadataValue = this.getLinkWithProtocol(parsedValue.value); + linkText = parsedValue.label; + } else { + // Use same value for link and label, correcting the protocol for link if needed + metadataValue = this.getLinkWithProtocol(this.metadataValue.value); + linkText = this.metadataValue.value; + } + + return { + href: metadataValue, + text: linkText, + }; + } + + /** + * Exctract label and values for TYPES.LABEL + * @param value + */ + parseLabelValue(input: string): { label: string; value: string } { + const match = input.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + + if (!match) { + return { + label: input, + value: input, + }; + } + + return { + label: match[1], + value: match[2], + }; + } + + getLinkWithProtocol(link: string): string { + const startsWithProtocol = [/^https?:\/\//, /^ftp:\/\//]; + return startsWithProtocol.some(rx => rx.test(link)) ? link : 'http://' + link; + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.html new file mode 100644 index 00000000000..d1e9271e72b --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.html @@ -0,0 +1,7 @@ +
+ + + + + +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.spec.ts new file mode 100644 index 00000000000..1dbb26ca25c --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.spec.ts @@ -0,0 +1,104 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { TruncatableComponent } from '../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { LonghtmlComponent } from './longhtml.component'; + +describe('LonghtmlComponent', () => { + let component: LonghtmlComponent; + let fixture: ComponentFixture; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'test item title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.title': [metadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + + const mockField: LayoutField = { + 'metadata': 'dc.title', + 'label': 'Title', + 'rendering': 'html', + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + LonghtmlComponent, + DsDatePipe, + ], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }).overrideComponent(LonghtmlComponent, { remove: { imports: [ TruncatableComponent, TruncatablePartComponent] } }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LonghtmlComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + expect(spanValueFound[0].nativeElement.textContent).toContain(metadataValue.value); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.ts new file mode 100644 index 00000000000..f5df21a6078 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longhtml/longhtml.component.ts @@ -0,0 +1,35 @@ +import { + Component, + OnInit, +} from '@angular/core'; + +import { TruncatableComponent } from '../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { HtmlComponent } from '../html/html.component'; + +/** + * This component renders the text metadata fields with a show more button + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-longhtml]', + templateUrl: './longhtml.component.html', + styleUrls: ['./longhtml.component.scss'], + imports: [ + TruncatableComponent, + TruncatablePartComponent, + ], +}) +export class LonghtmlComponent extends HtmlComponent implements OnInit { + + /** + * Id for truncatable component + */ + truncatableId: string; + + ngOnInit(): void { + this.truncatableId = `${this.item.id}_${this.field.metadata}_html`; + } + + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.html new file mode 100644 index 00000000000..3733e072b98 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.html @@ -0,0 +1,9 @@ +
+
+ + + + + +
+
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.spec.ts new file mode 100644 index 00000000000..10e06aa7295 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.spec.ts @@ -0,0 +1,101 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { TruncatableComponent } from '../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { LongtextComponent } from './longtext.component'; + +describe('LongtextComponent', () => { + let component: LongtextComponent; + let fixture: ComponentFixture; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.\nLorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.\nIt has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.\nIt was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.abstract': [metadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + + const mockField: LayoutField = { + 'metadata': 'dc.abstract', + 'label': 'Preferred name', + 'rendering': 'LONGTEXT', + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, LongtextComponent], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }) + .overrideComponent(LongtextComponent, { remove: { imports: [TruncatableComponent, TruncatablePartComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LongtextComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const formattedText = fixture.debugElement.query(By.css('[data-test="formatted-text"]')).nativeElement.innerHTML; + const breaklineCount = formattedText.match(/
/g)?.length; + + expect(breaklineCount).toBe(3); + expect(formattedText).toContain('<b>Lorem Ipsum</b>'); + + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.ts new file mode 100644 index 00000000000..b14aba3ef5e --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/longtext/longtext.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; + +import { TruncatableComponent } from '../../../../../../../shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../shared/truncatable/truncatable-part/truncatable-part.component'; +import { EscapeHtmlPipe } from '../../../../../../../shared/utils/escape-html.pipe'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the longtext metadata fields + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'div[ds-longtext]', + templateUrl: './longtext.component.html', + styleUrls: ['./longtext.component.scss'], + imports: [ + EscapeHtmlPipe, + TruncatableComponent, + TruncatablePartComponent, + ], +}) +export class LongtextComponent extends RenderingTypeValueModelComponent { + + /** + * Id for truncable component + */ + truncableId = `${this.item.id}_${this.field.metadata}`; + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadata-box-rendering-map.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadata-box-rendering-map.ts new file mode 100644 index 00000000000..27234c2b694 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadata-box-rendering-map.ts @@ -0,0 +1,78 @@ +import { AdvancedAttachmentComponent } from './advanced-attachment/advanced-attachment.component'; +import { AttachmentComponent } from './attachment/attachment.component'; +import { CcLicenseLargeComponent } from './cc-license-large/cc-license-large.component'; +import { CcLicenseSmallComponent } from './cc-license-small/cc-license-small.component'; +import { DateComponent } from './date/date.component'; +import { DynamicrefComponent } from './dynamicref/dynamicref.component'; +import { FieldRenderingType } from './field-rendering-type'; +import { HeadingComponent } from './heading/heading.component'; +import { HtmlComponent } from './html/html.component'; +import { IdentifierComponent } from './identifier/identifier.component'; +import { LinkComponent } from './link/link.component'; +import { LinkAuthorityComponent } from './link-authority/link-authority.component'; +import { LonghtmlComponent } from './longhtml/longhtml.component'; +import { LongtextComponent } from './longtext/longtext.component'; +import { InlineComponent } from './metadataGroup/inline/inline.component'; +import { TableComponent } from './metadataGroup/table/table.component'; +import { OrcidComponent } from './orcid/orcid.component'; +import { MetadataBoxFieldRenderOptions } from './rendering-type.model'; +import { TagComponent } from './tag/tag.component'; +import { TextComponent } from './text/text.component'; +import { ThumbnailRenderingComponent } from './thumbnail/thumbnail.component'; +import { ValuepairComponent } from './valuepair/valuepair.component'; + +export const layoutBoxesMap = new Map([ + [FieldRenderingType.TEXT, { componentRef: TextComponent, structured: false } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.HEADING, { componentRef: HeadingComponent, structured: false } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.LONGTEXT, { + componentRef: LongtextComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.DATE, { componentRef: DateComponent, structured: false } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.LINK, { componentRef: LinkComponent, structured: false } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.IDENTIFIER, { + componentRef: IdentifierComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.DYNAMICREF, { componentRef: DynamicrefComponent, structured: false } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.THUMBNAIL, { + componentRef: ThumbnailRenderingComponent, + structured: true, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.ATTACHMENT, { + componentRef: AttachmentComponent, + structured: true, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.TABLE, { componentRef: TableComponent, structured: true } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.INLINE, { componentRef: InlineComponent, structured: true } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.ORCID, { componentRef: OrcidComponent, structured: false } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.TAG, { componentRef: TagComponent, structured: true } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.VALUEPAIR, { + componentRef: ValuepairComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.ADVANCEDATTACHMENT, { + componentRef: AdvancedAttachmentComponent, + structured: true, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.AUTHORITYLINK, { + componentRef: LinkAuthorityComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.HTML, { + componentRef: HtmlComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.LONGHTML, { + componentRef: LonghtmlComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.CCLICENSEFULL, { + componentRef: CcLicenseLargeComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], + [FieldRenderingType.CCLICENSE, { + componentRef: CcLicenseSmallComponent, + structured: false, + } as MetadataBoxFieldRenderOptions], +]); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadata-box.decorator.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadata-box.decorator.ts new file mode 100644 index 00000000000..0bafdc50b30 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadata-box.decorator.ts @@ -0,0 +1,36 @@ +import { + hasValue, + isEmpty, +} from '@dspace/shared/utils/empty.util'; + +import { FieldRenderingType } from './field-rendering-type'; +import { MetadataBoxFieldRenderOptions } from './rendering-type.model'; + +/** + * Return the rendering type of the field to render + * + * @return the rendering type + */ +export const computeRenderingFn = (rendering: string, isSubtype = false): string | FieldRenderingType => { + let renderingType = hasValue(rendering) ? rendering : FieldRenderingType.TEXT; + + if (renderingType.indexOf('.') > -1) { + const values = renderingType.split('.'); + renderingType = isSubtype ? values[1] : values[0]; + } + return renderingType; +}; + +/** + * Return the rendering option related to the given rendering type + * @param layoutBoxesMap + * @param fieldRenderingType + */ +export const getMetadataBoxFieldRenderOptionsFn = (layoutBoxesMap: Map, fieldRenderingType: string): MetadataBoxFieldRenderOptions => { + let renderOptions = layoutBoxesMap.get(fieldRenderingType?.toUpperCase() as FieldRenderingType); + // If the rendering type not exists will use TEXT type rendering + if (isEmpty(renderOptions)) { + renderOptions = layoutBoxesMap.get(FieldRenderingType.TEXT); + } + return renderOptions; +}; diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.html new file mode 100644 index 00000000000..e1264666346 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.html @@ -0,0 +1,19 @@ +@if ((initialized | async)) { +
+ @for (index of componentsToBeRenderedMap.keys(); track index) { + + } +
+} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.spec.ts new file mode 100644 index 00000000000..8c2bbce56ee --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.spec.ts @@ -0,0 +1,139 @@ +import { + ChangeDetectionStrategy, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { DYNAMIC_FIELD_RENDERING_MAP } from '@dspace/config/app-config.interface'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { DsDatePipe } from '../../../../../../../pipes/ds-date.pipe'; +import { MetadataRenderComponent } from '../../../row/metadata-container/metadata-render/metadata-render.component'; +import { layoutBoxesMap } from '../../metadata-box-rendering-map'; +import { TextComponent } from '../../text/text.component'; +import { InlineComponent } from './inline.component'; + +describe('InlineComponent', () => { + let component: InlineComponent; + let fixture: ComponentFixture; + const testItem = Object.assign(new Item(), { + bundles: of({}), + metadata: { + 'dc.contributor.author': [ + { + value: 'Donohue, Tim', + }, + { + value: 'Surname, Name', + }, + ], + 'oairecerif.author.affiliation': [ + { + value: 'Duraspace', + }, + { + value: '4Science', + }, + ], + }, + }); + const mockField = Object.assign({ + id: 1, + fieldType: 'METADATAGROUP', + metadata: 'dc.contributor.author', + label: 'Author(s)', + rendering: 'inline', + style: 'container row', + styleLabel: 'fw-bold col-4', + styleValue: 'col', + metadataGroup: { + leading: 'dc.contributor.author', + elements: [ + { + metadata: 'dc.contributor.author', + label: 'Author(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'fw-bold col-0', + styleValue: 'col', + }, + { + metadata: 'oairecerif.author.affiliation', + label: 'Affiliation(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'fw-bold col-0', + styleValue: 'col', + }, + ], + }, + }) as LayoutField; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, DsDatePipe, + MetadataRenderComponent, + InlineComponent, + TextComponent], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: DYNAMIC_FIELD_RENDERING_MAP, useValue: layoutBoxesMap }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(InlineComponent, { + set: { changeDetection: ChangeDetectionStrategy.OnPush }, + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(InlineComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const rowsFound = fixture.debugElement.queryAll(By.css('div.metadata-group-entry')); + expect(rowsFound.length).toBe(2); + + let divFound = fixture.debugElement.query(By.css('div.metadata-group-entry:nth-child(1)')); + + let span = divFound.query(By.css('span.metadata-group-entry-value:nth-child(1)')); + expect(span.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[0].metadata][0].value); + span = divFound.query(By.css('span.metadata-group-entry-value:nth-child(2)')); + expect(span.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[1].metadata][0].value); + + divFound = fixture.debugElement.query(By.css('div.metadata-group-entry:nth-child(2)')); + span = divFound.query(By.css('span.metadata-group-entry-value:nth-child(1)')); + expect(span.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[0].metadata][1].value); + span = divFound.query(By.css('span.metadata-group-entry-value:nth-child(2)')); + expect(span.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[1].metadata][1].value); + done(); + + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.ts new file mode 100644 index 00000000000..73a48aecac4 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/inline/inline.component.ts @@ -0,0 +1,42 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { LayoutField } from '../../../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../../../core/shared/item.model'; +import { MetadataRenderComponent } from '../../../row/metadata-container/metadata-render/metadata-render.component'; +import { MetadataGroupComponent } from '../metadata-group.component'; + +/** + * This component renders the inline metadata group fields + */ +@Component({ + selector: 'ds-inline', + templateUrl: './inline.component.html', + styleUrls: ['./inline.component.scss'], + imports: [ + AsyncPipe, + MetadataRenderComponent, + NgClass, + ], +}) +export class InlineComponent extends MetadataGroupComponent implements OnInit { + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/metadata-group.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/metadata-group.component.ts new file mode 100644 index 00000000000..296fbd59a52 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/metadata-group.component.ts @@ -0,0 +1,116 @@ +import { + Component, + Inject, + OnDestroy, + OnInit, +} from '@angular/core'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { RenderingTypeStructuredModelComponent } from '../rendering-type-structured.model'; + + +export interface NestedMetadataGroupEntry { + field: LayoutField; + value: MetadataValue; +} + +@Component({ + template: '', +}) +export abstract class MetadataGroupComponent extends RenderingTypeStructuredModelComponent implements OnInit, OnDestroy { + + /** + * This property is used to hold nested Layout Field inside a metadata group field + */ + metadataGroup: LayoutField[] = []; + + /** + * This property is used to hold a list of objects with nested Layout Field + * and an index that shows the position of nested field inside metadata group field + */ + componentsToBeRenderedMap: Map = new Map(); + + /** + * The prefix used for box field label's i18n key + */ + readonly fieldI18nPrefix = 'layout.field.label'; + + /** + * The prefix used for box field label's + */ + readonly nestedMetadataPrefix = 'NESTED'; + + /** + * A boolean representing if component is initialized + */ + initialized: BehaviorSubject = new BehaviorSubject(false); + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + ngOnInit() { + this.field.metadataGroup.elements.forEach((entry: LayoutField) => { + if (this.item.metadata[entry.metadata]) { + const styleValue = !entry.styleValue ? this.field.styleValue : (entry.styleValue + this.field.styleValue); + this.metadataGroup.push(Object.assign({}, entry, { styleValue: styleValue }) ); + } + }); + this.metadataValues.forEach((mdv, index) => { + this.metadataGroup.forEach(mdg => { + const entry = { + field: mdg, + value: this.getMetadataValue(mdg, index), + } as NestedMetadataGroupEntry; + if (this.componentsToBeRenderedMap.has(index)) { + const newEntries = [...this.componentsToBeRenderedMap.get(index), entry]; + this.componentsToBeRenderedMap.set(index, newEntries); + } else { + this.componentsToBeRenderedMap.set(index, [entry]); + } + }); + }); + + this.initialized.next(true); + } + + getMetadataValue(field: LayoutField, index: number): MetadataValue { + const metadataList = this.item.findMetadataSortedByPlace(field.metadata); + return isNotEmpty(metadataList[index]) ? metadataList[index] : null; + } + + /** + * Returns the translated label, if exists, otherwiuse returns a fallback value + */ + getLabel(field: LayoutField): string { + return this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.item.entityType}.${this.nestedMetadataPrefix}[${field.metadata}]`) ?? + this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.item.entityType}.[${field.metadata}]`) ?? + this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.item.entityType}.${field.metadata}`) ?? // old syntax - do not use + this.getTranslationIfExists(`${this.fieldI18nPrefix}.[${field.metadata}]`) ?? + this.getTranslationIfExists(`${this.fieldI18nPrefix}.${field.label}`) ?? // old syntax - do not use + field.label; // the untranslated value from the dynamic layout + } + + /** + * Return the translated label, if exists, otherwise returns null + */ + getTranslationIfExists(key: string): string { + const translation: string = this.translateService.instant(key); + return translation !== key ? translation : null; + } + + ngOnDestroy(): void { + this.componentsToBeRenderedMap = null; + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.html new file mode 100644 index 00000000000..739ad53167c --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.html @@ -0,0 +1,27 @@ +@if ((initialized | async)) { +
+
+ + + @for (field of metadataGroup; track field) { + + } + + @for (index of componentsToBeRenderedMap.keys(); track index) { + + @for (mdg of componentsToBeRenderedMap.get(index); track mdg) { + + } + + } +
+ +
+ +
+
+
+} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.scss new file mode 100644 index 00000000000..6c963897aea --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.scss @@ -0,0 +1,8 @@ +.table-container { + margin-left: -0.75rem !important; + margin-right: -0.75rem !important; +} + +td { + word-break: auto-phrase; +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.spec.ts new file mode 100644 index 00000000000..2acefffb1dc --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.spec.ts @@ -0,0 +1,146 @@ +import { ChangeDetectionStrategy } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DYNAMIC_FIELD_RENDERING_MAP } from '@dspace/config/app-config.interface'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { DsDatePipe } from '../../../../../../../pipes/ds-date.pipe'; +import { MetadataRenderComponent } from '../../../row/metadata-container/metadata-render/metadata-render.component'; +import { FieldRenderingType } from '../../field-rendering-type'; +import { layoutBoxesMap } from '../../metadata-box-rendering-map'; +import { TextComponent } from '../../text/text.component'; +import { TableComponent } from './table.component'; + +describe('TableComponent', () => { + let component: TableComponent; + let fixture: ComponentFixture; + const testItem = Object.assign(new Item(), { + uuid: 'itemUUID', + id: 'itemUUID', + metadata: { + 'dc.contributor.author': [ + { + value: 'Donohue, Tim', + }, + { + value: 'Surname, Name', + }, + ], + 'oairecerif.author.affiliation': [ + { + value: 'Duraspace', + }, + { + value: '4Science', + }, + ], + }, + _links: { + self: { href: 'item-selflink' }, + }, + }); + + const mockField = Object.assign({ + id: 1, + metadata: 'dc.contributor.author', + fieldType: 'METADATAGROUP', + label: 'Author(s)', + rendering: FieldRenderingType.TABLE, + style: 'container row', + styleLabel: 'fw-bold col-4', + styleValue: 'col', + metadataGroup: { + leading: 'dc.contributor.author', + elements: [ + { + metadata: 'dc.contributor.author', + label: 'Author(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'fw-bold col-0', + styleValue: 'col', + }, + { + metadata: 'oairecerif.author.affiliation', + label: 'Affiliation(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'fw-bold col-0', + styleValue: 'col', + }, + ], + }, + }) as LayoutField; + + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), DsDatePipe, + MetadataRenderComponent, + TableComponent, + TextComponent], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: DYNAMIC_FIELD_RENDERING_MAP, useValue: layoutBoxesMap }, + ], + }).overrideComponent(TableComponent, { + set: { changeDetection: ChangeDetectionStrategy.OnPush }, + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(TableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', (done) => { + expect(component).toBeTruthy(); + done(); + + }); + + it('check metadata rendering', (done) => { + const rowsFound = fixture.debugElement.queryAll(By.css('tr')); + expect(rowsFound.length).toBe(3); + + let rowFound = fixture.debugElement.query(By.css('tr:nth-child(1)')); + let td = rowFound.query(By.css('td:nth-child(1)')); + expect(td.nativeElement.textContent).toContain(mockField.metadataGroup.elements[0].label); + td = rowFound.query(By.css('td:nth-child(2)')); + expect(td.nativeElement.textContent).toContain(mockField.metadataGroup.elements[1].label); + + rowFound = fixture.debugElement.query(By.css('tr:nth-child(2)')); + td = rowFound.query(By.css('td:nth-child(1)')); + expect(td.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[0].metadata][0].value); + td = rowFound.query(By.css('td:nth-child(2)')); + expect(td.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[1].metadata][0].value); + + rowFound = fixture.debugElement.query(By.css('tr:nth-child(3)')); + td = rowFound.query(By.css('td:nth-child(1)')); + expect(td.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[0].metadata][1].value); + td = rowFound.query(By.css('td:nth-child(2)')); + expect(td.nativeElement.textContent).toContain(testItem.metadata[mockField.metadataGroup.elements[1].metadata][1].value); + done(); + + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.ts new file mode 100644 index 00000000000..2568c28d60e --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadataGroup/table/table.component.ts @@ -0,0 +1,21 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; + +import { MetadataRenderComponent } from '../../../row/metadata-container/metadata-render/metadata-render.component'; +import { MetadataGroupComponent } from '../metadata-group.component'; + +/** + * This component renders the table metadata group fields + */ +@Component({ + selector: 'ds-table', + templateUrl: './table.component.html', + styleUrls: ['./table.component.scss'], + imports: [ + AsyncPipe, + MetadataRenderComponent, + ], +}) +export class TableComponent extends MetadataGroupComponent { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.html new file mode 100644 index 00000000000..0a4689f396b --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.html @@ -0,0 +1,23 @@ +
+ + + @if ((orcidUrl$ | async)) { + + {{ metadataValue.value }} + + } @else { + {{ metadataValue.value }} + } + + + @if (hasOrcidBadge()) { + orcid-logo + } + + +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.scss new file mode 100644 index 00000000000..b92a52cd35d --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.scss @@ -0,0 +1,4 @@ +.orcid-icon { + height: 1.2rem; + padding-left: 0.3rem; +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.spec.ts new file mode 100644 index 00000000000..7350e54dc3b --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.spec.ts @@ -0,0 +1,238 @@ +/* +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; + +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { OrcidComponent } from './orcid.component'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { TranslateLoaderMock } from '../../../../../../../shared/mocks/translate-loader.mock'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { ConfigurationDataService } from '../../../../../../../core/data/configuration-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../../../shared/remote-data.utils'; +import { MetadataBoxConfiguration } from '../../../../../../../core/layout/models/box.model'; + +export const testItem: Item = Object.assign(new Item(), { + id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', + uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', + type: 'item', + metadata: { + 'person.identifier.orcid': [ + { + language: 'en_US', + value: '0000-0001-8918-3592' + } + ], + 'dspace.orcid.authenticated': [ + { + language: null, + value: 'authenticated' + } + ] + } +}); + +export const medataComponent: MetadataBoxConfiguration = { + id: 'testTagBox', + type: 'boxmetadataconfiguration', + rows: [{ + style: 'row-style', + cells: [{ + style: 'cell-style', + fields: [ + { + metadata: 'person.identifier.orcid', + label: 'ORCID', + rendering: 'orcid', + fieldType: 'metadata', + labelAsHeading: true, + valuesInline: true + } + ] + }] + }] +}; + +describe('OrcidComponent', () => { + let component: OrcidComponent; + let fixture: ComponentFixture; + let configurationDataService; + + beforeEach(fakeAsync(() => { + + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['https://sandbox.orcid.org'] }) + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), BrowserAnimationsModule], + declarations: [ OrcidComponent, DsDatePipe ], + providers: [ { provide: ConfigurationDataService, useValue: configurationDataService}] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OrcidComponent); + component = fixture.componentInstance; + component.item = testItem; + component.field = medataComponent.rows[0].fields[0]; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('check metadata rendering', fakeAsync(() => { + tick(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.txt-value')); + expect(spanValueFound.length).toBe(1); + expect(spanValueFound[0].nativeElement.textContent).toContain('0000-0001-8918-3592'); + + const orcidLinkFound = fixture.debugElement.queryAll(By.css('a')); + expect(orcidLinkFound.length).toBe(1); + expect(orcidLinkFound[0].nativeElement.href).toBe('https://sandbox.orcid.org/0000-0001-8918-3592'); + + const orcidIconFound = fixture.debugElement.queryAll(By.css('.orcid-icon')); + expect(orcidIconFound.length).toBe(1); + expect(orcidIconFound[0].nativeElement.src).toContain('assets/images/orcid.logo.icon.svg'); + + const spanLabelFound = fixture.debugElement.query(By.css('div.' + medataComponent.rows[0].fields[0].style)); + const label: HTMLElement = spanLabelFound.nativeElement; + expect(label.textContent).toContain(medataComponent.rows[0].fields[0].label); + }); + + })); +}); +*/ + +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +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 MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { FieldRenderingType } from '../field-rendering-type'; +import { OrcidComponent } from './orcid.component'; + +describe('OrcidComponent', () => { + let component: OrcidComponent; + 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', + }, + ); + + const mockField: LayoutField = { + 'metadata': 'person.identifier.orcid', + 'label': 'ORCID', + 'rendering': FieldRenderingType.ORCID, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, OrcidComponent, DsDatePipe], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OrcidComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', fakeAsync(() => { + tick(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.txt-value')); + expect(spanValueFound.length).toBe(1); + expect(spanValueFound[0].nativeElement.textContent).toContain('0000-0001-8918-3592'); + + const orcidLinkFound = fixture.debugElement.queryAll(By.css('a')); + expect(orcidLinkFound.length).toBe(1); + expect(orcidLinkFound[0].nativeElement.href).toBe('https://sandbox.orcid.org/0000-0001-8918-3592'); + + const orcidIconFound = fixture.debugElement.queryAll(By.css('.orcid-icon')); + expect(orcidIconFound.length).toBe(1); + expect(orcidIconFound[0].nativeElement.src).toContain('assets/images/orcid.logo.icon.svg'); + }); + + })); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); +}); + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.ts new file mode 100644 index 00000000000..ab09122c4c9 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/orcid/orcid.component.ts @@ -0,0 +1,68 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ConfigurationDataService } from '../../../../../../../core/data/configuration-data.service'; +import { LayoutField } from '../../../../../../../core/layout/models/box.model'; +import { ConfigurationProperty } from '../../../../../../../core/shared/configuration-property.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../../core/shared/operators'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the text metadata fields + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-orcid]', + templateUrl: './orcid.component.html', + styleUrls: ['./orcid.component.scss'], + imports: [ + AsyncPipe, + NgbTooltipModule, + TranslateModule, + ], +}) +export class OrcidComponent extends RenderingTypeValueModelComponent implements OnInit { + + orcidUrl$: Observable; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('metadataValueProvider') public metadataValueProvider: MetadataValue, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + private configurationService: ConfigurationDataService, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, metadataValueProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + ngOnInit() { + this.orcidUrl$ = this.configurationService.findByPropertyName('orcid.domain-url').pipe( + getFirstSucceededRemoteDataPayload(), + map((property: ConfigurationProperty) => property?.values?.length > 0 ? property.values[0] : null), + ); + } + + public hasOrcid(): boolean { + return this.item.hasMetadata('person.identifier.orcid'); + } + + public hasOrcidBadge(): boolean { + return this.item.hasMetadata('dspace.orcid.authenticated'); + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type-structured.model.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type-structured.model.ts new file mode 100644 index 00000000000..87ce1dcdddd --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type-structured.model.ts @@ -0,0 +1,34 @@ +import { + Component, + Inject, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { LayoutField } from '../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../core/shared/item.model'; +import { RenderingTypeModelComponent } from './rendering-type.model'; + +/** + * This class defines the basic model to extends for create a new + * field render component + */ +@Component({ + template: '', +}) +export abstract class RenderingTypeStructuredModelComponent extends RenderingTypeModelComponent { + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(translateService); + this.field = fieldProvider; + this.item = itemProvider; + this.renderingSubType = renderingSubTypeProvider; + this.tabName = tabNameProvider; + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type-value.model.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type-value.model.ts new file mode 100644 index 00000000000..0f4543ebde2 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type-value.model.ts @@ -0,0 +1,52 @@ +import { + Component, + Inject, + Input, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { LayoutField } from '../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../core/shared/item.model'; +import MetadataValue from '../../../../../../core/shared/metadata.models'; +import { RenderingTypeModelComponent } from './rendering-type.model'; + +/** + * This class defines the basic model to extends for create a new + * field render component + */ +@Component({ + template: '', +}) +export abstract class RenderingTypeValueModelComponent extends RenderingTypeModelComponent { + + /** + * Current metadata value to render + */ + @Input() metadataValue: MetadataValue; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('metadataValueProvider') public metadataValueProvider: MetadataValue, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(translateService); + this.field = fieldProvider; + this.item = itemProvider; + this.metadataValue = metadataValueProvider; + this.renderingSubType = renderingSubTypeProvider; + this.tabName = tabNameProvider; + } + + /** + * Purge all HTML tags, then replace newline character with
+ * @param text + */ + formatText(text: string): string { + const newlineRegex = /\n/g; + return text.replace(/>/g, '>').replace(/'); + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type.model.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type.model.ts new file mode 100644 index 00000000000..9555d27fee1 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type.model.ts @@ -0,0 +1,115 @@ +import { + Component, + Input, +} from '@angular/core'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { GenericConstructor } from '@dspace/core/shared/generic-constructor'; +import { Item } from '@dspace/core/shared/item.model'; +import MetadataValue from '@dspace/core/shared/metadata.models'; +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; + + +export interface MetadataBoxFieldRenderOptions { + componentRef: GenericConstructor; + structured: boolean; +} + +/** + * This class defines the basic model to extends for create a new + * field render component + */ +@Component({ + template: '', +}) +export abstract class RenderingTypeModelComponent { + + /** + * Current DSpace item + */ + @Input() item: Item; + /** + * Current field + */ + @Input() field: LayoutField; + /** + * The rendering sub-type, if exists + * e.g. for type identifier.doi this property + * contains the sub-type doi + */ + @Input() renderingSubType: string; + + /** + * Returns the value of the metadata to show + */ + @Input() nested: boolean; + + @Input() indexToBeRendered; + + /** + * The tab name + */ + @Input() tabName: string; + + /** + * The prefix used for box field label's i18n key + */ + fieldI18nPrefix = 'layout.field.label.'; + + constructor(protected translateService: TranslateService) { + } + + /** + * Returns all metadata values in the item + */ + get metadataValues(): string[] { + return this.field.metadata ? this.item.allMetadataValues(this.field.metadata) : []; + } + + get metadata(): MetadataValue[] { + return this.field.metadata ? this.item.allMetadata(this.field.metadata) : []; + } + + /** + * Returns true if the field has label, false otherwise + */ + get hasLabel(): boolean { + return hasValue(this.field.label); + } + + /** + * Returns a string representing the label of field if exists + */ + get label(): string { + const fieldLabelI18nKey = this.fieldI18nPrefix + this.field.label; + const header: string = this.translateService.instant(fieldLabelI18nKey); + if (header === fieldLabelI18nKey ) { + // if translation does not exist return the value present in the header property + return this.translateService.instant(this.field.label); + } else { + return header; + } + } + + /** + * Returns a string representing the style of field container if exists + */ + get containerStyle(): string { + return this.field.style; + } + + /** + * Returns a string representing the style of field label if exists + */ + get labelStyle(): string { + return this.field.styleLabel; + } + + /** + * Returns a string representing the style of field value if exists + */ + get valueStyle(): string { + return this.field.styleValue || ''; + } +} + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.html new file mode 100644 index 00000000000..1d7c194a0a4 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.html @@ -0,0 +1,9 @@ +
+ @if (chips && chips.hasItems()) { + + } +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.scss new file mode 100644 index 00000000000..2887ae0e104 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.scss @@ -0,0 +1,3 @@ +:host ::ng-deep p { + color: #fff; +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.spec.ts new file mode 100644 index 00000000000..d6dfaee9289 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.spec.ts @@ -0,0 +1,82 @@ +/*import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; + +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { TagComponent } from './tag.component'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { tagMedataComponent } from '../../../../../../../shared/testing/tag-metadata-components.mock'; +import { TranslateLoaderMock } from '../../../../../../../shared/mocks/translate-loader.mock'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { SharedModule } from '../../../../../../../shared/shared.module'; +import { UploaderService } from '../../../../../../../shared/uploader/uploader.service'; + +class TestItem { + allMetadataValues(key: string): string[] { + return ['HKU', 'ASDF']; + } +} + +describe('TagComponent', () => { + let component: TagComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), BrowserAnimationsModule, SharedModule], + declarations: [ TagComponent, DsDatePipe ], + providers : [ + { provide: UploaderService, useValue: {} }, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TagComponent); + component = fixture.componentInstance; + component.item = new TestItem() as Item; + component.field = tagMedataComponent.rows[0].fields[0]; + fixture.detectChanges(); + }); + + + it('should have the right label', (done) => { + const spanLabelFound = fixture.debugElement.query(By.css('div.' + tagMedataComponent.rows[0].fields[0].style + ' > span')); + expect(spanLabelFound.nativeElement.textContent.trim()).toBe(tagMedataComponent.rows[0].fields[0].label); + done(); + }); + + it('should have chips', () => { + const chips = fixture.debugElement.query(By.css('ds-chips')); + expect(chips).toBeTruthy(); + }); + + + it('should have the right chip values if it has no indexToBeRendered', (done) => { + const chipLabelsFound = fixture.debugElement.queryAll(By.css('p.chip-label')); + expect(chipLabelsFound[0].nativeElement.textContent).toContain((new TestItem()).allMetadataValues('')[0]); + expect(chipLabelsFound[1].nativeElement.textContent).toContain((new TestItem()).allMetadataValues('')[1]); + done(); + }); + + + it('should render single chip item if it has indexToBeRendered', (done) => { + + component.indexToBeRendered = 1; + component.ngOnInit(); + fixture.detectChanges(); + + const chipLabelsFound = fixture.debugElement.queryAll(By.css('p.chip-label')); + expect(chipLabelsFound.length).toBe(1); + expect(chipLabelsFound[0].nativeElement.textContent).toContain((new TestItem()).allMetadataValues('')[1]); + done(); + }); + +});*/ diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.ts new file mode 100644 index 00000000000..7089fa8fb9a --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/tag/tag.component.ts @@ -0,0 +1,63 @@ + +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { LayoutField } from '../../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { ChipsComponent } from '../../../../../../../shared/form/chips/chips.component'; +import { Chips } from '../../../../../../../shared/form/chips/models/chips.model'; +import { RenderingTypeStructuredModelComponent } from '../rendering-type-structured.model'; + +/** + * This component renders the tag metadata fields + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-tag]', + templateUrl: './tag.component.html', + styleUrls: ['./tag.component.scss'], + imports: [ + ChipsComponent, + ], +}) +export class TagComponent extends RenderingTypeStructuredModelComponent implements OnInit { + + /** + * This is the chips component which will be rendered in the template + */ + public chips: Chips; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + /** + * Initializes chips only for the rendered index value if indexToBeRendered is set or + * it initializes chips for all values. + */ + ngOnInit() { + if ( this.indexToBeRendered > 0 ) { + this.initChips([this.metadataValues[this.indexToBeRendered]]); + } else { + this.initChips(this.metadataValues); + } + } + + /** + * Creates the chips component with the required values + * @params initChipsValues values to be rendered in chip items + */ + private initChips(initChipsValues: string[]): void { + this.chips = new Chips(initChipsValues,'value'); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.html new file mode 100644 index 00000000000..7d872b86b34 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.spec.ts new file mode 100644 index 00000000000..06361266fd8 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.spec.ts @@ -0,0 +1,97 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { TextComponent } from './text.component'; + +describe('TextComponent', () => { + let component: TextComponent; + let fixture: ComponentFixture; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'test item title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.title': [metadataValue], + }, + uuid: 'test-item-uuid', + }, + ); + + + const mockField: LayoutField = { + 'metadata': 'dc.title', + 'label': 'Title', + 'rendering': null, + 'fieldType': 'METADATA', + 'style': null, + 'styleLabel': 'test-style-label', + 'styleValue': 'test-style-value', + 'labelAsHeading': false, + 'valuesInline': true, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, TextComponent, DsDatePipe], + providers: [ + { provide: 'fieldProvider', useValue: mockField }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'metadataValueProvider', useValue: metadataValue }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TextComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('check metadata rendering', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('span.text-value')); + expect(spanValueFound.length).toBe(1); + expect(spanValueFound[0].nativeElement.textContent).toContain(metadataValue.value); + done(); + }); + + it('check value style', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-value')); + expect(spanValueFound.length).toBe(1); + done(); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.ts new file mode 100644 index 00000000000..446f5cead2b --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/text/text.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; + +import { EscapeHtmlPipe } from '../../../../../../../shared/utils/escape-html.pipe'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the text metadata fields + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-text]', + templateUrl: './text.component.html', + styleUrls: ['./text.component.scss'], + imports: [ + EscapeHtmlPipe, + ], +}) +export class TextComponent extends RenderingTypeValueModelComponent { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.html new file mode 100644 index 00000000000..1843d9baedb --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.html @@ -0,0 +1,8 @@ +@if ((initialized | async)) { +
+ + +
+} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.scss new file mode 100644 index 00000000000..dad678eac08 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.scss @@ -0,0 +1,3 @@ +.thumbnail-container { + min-width: var(--ds-thumbnail-max-width); +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.spec.ts new file mode 100644 index 00000000000..4b5859df3b6 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.spec.ts @@ -0,0 +1,344 @@ +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { + bitstreamOrignialWithMetadata, + bitstreamWithoutThumbnail, + bitstreamWithThumbnail, + bitstreamWithThumbnailWithMetadata, + mockThumbnail, + mockThumbnailWithType, +} from '@dspace/core/testing/bitstreams.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { ThemedThumbnailComponent } from '../../../../../../../thumbnail/themed-thumbnail.component'; +import { FieldRenderingType } from '../field-rendering-type'; +import { ThumbnailRenderingComponent } from './thumbnail.component'; +import { ThumbnailService } from './thumbnail.service'; + +describe('', () => { + let component: ThumbnailRenderingComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const testItem = Object.assign(new Item(), { + bundles: of({}), + metadata: { + 'dc.identifier.doi': [ + { + value: 'doi:10.1392/dironix', + }, + ], + }, + entityType: 'Person', + }); + + const mockField = Object.assign({ + id: 1, + label: 'Field Label', + metadata: 'dc.identifier.doi', + rendering: FieldRenderingType.THUMBNAIL, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'fw-bold col-3', + styleValue: null, + bitstream: { + bundle: 'ORIGINAL', + metadataField: null, + metadataValue: null, + }, + }); + + const mockField1 = Object.assign({ + id: 1, + label: 'Field Label', + metadata: 'dc.identifier.doi', + rendering: FieldRenderingType.THUMBNAIL, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'fw-bold col-3', + styleValue: null, + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: 'personal picture', + }, + }); + + const mockBitstreamDataService = jasmine.createSpyObj('BitstreamDataService', { + findByItem: jasmine.createSpy('findByItem'), + }); + + const mockAuthorizedService = jasmine.createSpyObj('AuthorizationDataService', { + isAuthorized: jasmine.createSpy('isAuthorized'), + }); + + const mockThumbnailService = jasmine.createSpyObj('ThumbnailService', { + getConfig: jasmine.createSpy('getConfig'), + }); + + const getDefaultTestBedConf = (fieldProvider) => { + return { + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ThumbnailRenderingComponent, + ], + providers: [ + { provide: 'fieldProvider', useValue: fieldProvider }, + { provide: 'itemProvider', useValue: testItem }, + { provide: 'renderingSubTypeProvider', useValue: '' }, + { provide: 'tabNameProvider', useValue: '' }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: AuthorizationDataService, useValue: mockAuthorizedService }, + { provide: ConfigurationDataService, useValue: {} }, + { provide: ThumbnailService, useValue: mockThumbnailService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }; + }; + + describe('When there is no metadata field to check', () => { + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule(getDefaultTestBedConf(mockField)) + .overrideComponent(ThumbnailRenderingComponent, { + remove: { + imports: [ + ThemedThumbnailComponent, + ], + }, + }); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ThumbnailRenderingComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + mockBitstreamDataService.findByItem.and.returnValues(createSuccessfulRemoteDataObject$(createPaginatedList([]))); + mockThumbnailService.getConfig.and.returnValue(of(createSuccessfulRemoteDataObject(null))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('findByItem should have been called', () => { + expect(mockBitstreamDataService.findByItem).toHaveBeenCalled(); + }); + + describe('When bitstreams are empty', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('check metadata rendering', fakeAsync(() => { + const thumbnail = fixture.debugElement.query(By.css('[data-test="thumbnail"]')); + expect(thumbnail).toBeTruthy(); + })); + + it('should show default thumbnail', (done) => { + component.default$.subscribe(image => { + expect(image).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); + }); + + describe('When bitstreams are only original', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstreamWithoutThumbnail]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('check metadata rendering', fakeAsync(() => { + const thumbnail = fixture.debugElement.query(By.css('[data-test="thumbnail"]')); + expect(thumbnail).toBeTruthy(); + })); + + it('should show default thumbnail', (done) => { + component.default$.subscribe(image => { + expect(image).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); + }); + + describe('When bitstreams are only thumbnail', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstreamWithThumbnail]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('check metadata rendering', fakeAsync(() => { + const thumbnail = fixture.debugElement.query(By.css('[data-test="thumbnail"]')); + expect(thumbnail).toBeTruthy(); + })); + + it('should show thumbnail content image src', () => { + expect(component.thumbnail$.value).toEqual(mockThumbnail); + }); + + }); + + }); + + + describe('When there is dc.type metadata field to check', () => { + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule(getDefaultTestBedConf(mockField1)) + .overrideComponent(ThumbnailRenderingComponent, { + remove: { + imports: [ + ThemedThumbnailComponent, + ], + }, + }); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ThumbnailRenderingComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + mockBitstreamDataService.findByItem.and.returnValue(of([])); + mockThumbnailService.getConfig.and.returnValue(of(createSuccessfulRemoteDataObject(null))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('findByItem should have been called', () => { + expect(mockBitstreamDataService.findByItem).toHaveBeenCalled(); + }); + + describe('When bitstreams are empty', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('check metadata rendering', () => { + const thumbnail = fixture.debugElement.query(By.css('[data-test="thumbnail"]')); + expect(thumbnail).toBeTruthy(); + }); + + it('should show default thumbnail', (done) => { + component.default$.subscribe(image => { + expect(image).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); + }); + + describe('When bitstreams are only original without the right metadata information', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstreamWithoutThumbnail]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should not show bitstream content image src but the default image', (done) => { + component.default$.subscribe(image => { + expect(image).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); + + }); + + describe('When bitstreams are thumbnail of original without the right metadata information', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should not show thumbnail content image src but the default image', (done) => { + component.default$.subscribe(image => { + expect(image).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); + }); + + describe('When bitstreams are only original with the right metadata information', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstreamOrignialWithMetadata]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should not show thumbnail content image src but the default image', (done) => { + component.default$.subscribe(image => { + expect(image).toBe('assets/images/file-placeholder.svg'); + done(); + }); + }); + + }); + + describe('When bitstreams thumbnail of original bitsream with the right metadata information', () => { + + beforeEach(() => { + const spy = spyOn(component, 'getBitstreamsByItem'); + spy.and.returnValue(of(createPaginatedList([bitstreamWithThumbnailWithMetadata]))); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should show thumbnail content image src', () => { + expect(component.thumbnail$.value).toEqual(mockThumbnailWithType); + }); + + }); + + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.ts new file mode 100644 index 00000000000..259fcc0045a --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.component.ts @@ -0,0 +1,118 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { + BehaviorSubject, + combineLatest, + Observable, + of, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { getDefaultImageUrlByEntityType } from '../../../../../../../shared/image.utils'; +import { ThemedThumbnailComponent } from '../../../../../../../thumbnail/themed-thumbnail.component'; +import { BitstreamRenderingModelComponent } from '../bitstream-rendering-model'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector, dspace-angular-ts/themed-component-selectors + selector: 'span[ds-thumbnail].float-start', + templateUrl: './thumbnail.component.html', + styleUrls: ['./thumbnail.component.scss'], + imports: [ + AsyncPipe, + ThemedThumbnailComponent, + ], +}) +/** + * The component for displaying a thumbnail rendered metadata box + */ +export class ThumbnailRenderingComponent extends BitstreamRenderingModelComponent implements OnInit { + + /** + * The bitstream to be rendered + */ + thumbnail$: BehaviorSubject = new BehaviorSubject(null); + + /** + * Default image to be shown in the thumbnail + */ + default$: Observable; + + /** + * Item rendering initialization state + */ + initialized: BehaviorSubject = new BehaviorSubject(false); + + /** + * Maximum size of the thumbnail allowed to be shown + */ + maxSize: number; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected bitstreamDataService: BitstreamDataService, + protected translateService: TranslateService, + ) { + super(fieldProvider, itemProvider, renderingSubTypeProvider, tabNameProvider, bitstreamDataService, translateService); + } + + /** + * Get the thumbnail information from api for this item + */ + ngOnInit(): void { + const eType = this.item.firstMetadataValue('dspace.entity.type'); + this.default$ = getDefaultImageUrlByEntityType(eType); + + combineLatest([ + this.default$, + this.getBitstreamsByItem(), + ]).pipe( + map(([_, bitstreamList]: [string, PaginatedList]) => bitstreamList.page), + switchMap((filteredBitstreams: Bitstream[]) => { + if (filteredBitstreams.length > 0) { + if (isEmpty(filteredBitstreams[0].thumbnail)) { + return of(null); + } else { + return filteredBitstreams[0].thumbnail.pipe( + getFirstCompletedRemoteData(), + map((thumbnailRD) => { + if (thumbnailRD.hasSucceeded && isNotEmpty(thumbnailRD.payload)) { + return thumbnailRD.payload; + } else { + return null; + } + }), + ); + } + } else { + return of(null); + } + }), + ).subscribe((thumbnail: Bitstream) => { + if (isNotEmpty(thumbnail)) { + this.thumbnail$.next(thumbnail); + } + this.initialized.next(true); + }); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.service.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.service.ts new file mode 100644 index 00000000000..89f92960591 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/thumbnail/thumbnail.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { Observable } from 'rxjs'; + + + +/** + * Set up Google Analytics on the client side. + * See: {@link addTrackingIdToPage}. + */ +@Injectable({ providedIn: 'root' }) +export class ThumbnailService { + + constructor( + private configService: ConfigurationDataService, + ) { } + + /** + * Call this method once when Angular initializes on the client side. + * It requests Thumbnail Maximum Size from the rest backend + * (property: dspace.layout.thumbnail.maxsize) + */ + getConfig(): Observable> { + return this.configService.findByPropertyName('dynamic.layout.thumbnail.maxsize').pipe( + getFirstCompletedRemoteData(), + ); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.html new file mode 100644 index 00000000000..52cc9aeadef --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.html @@ -0,0 +1,11 @@ +
+ @if (isMetadataLink !== true) { + + {{ value$ | async }} + + } @else { + + {{ value$ | async }} + + } +
diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.spec.ts new file mode 100644 index 00000000000..2a37ffb7707 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.spec.ts @@ -0,0 +1,264 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; +import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { + EMPTY, + of, +} from 'rxjs'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { DsDatePipe } from '../../../../../../pipes/ds-date.pipe'; +import { ValuepairComponent } from './valuepair.component'; + +const METADATA_KEY_1 = 'person.knowsLanguage'; +const METADATA_KEY_2 = 'dc.type'; + +const VOCABULARY_NAME_1 = 'common_iso_languages'; +const VOCABULARY_NAME_2 = 'types'; + +describe('ValuepairComponent', () => { + + let component: ValuepairComponent; + let fixture: ComponentFixture; + + let vocabularyService: VocabularyService; + const authService = new AuthServiceStub(); + + const allMetadata = (key: string) => { + switch (key) { + case METADATA_KEY_1: + return [ + { value: 'it', authority: null }, + { value: 'en', authority: null }, + ]; + case METADATA_KEY_2: + return [ + { value: undefined, authority: VOCABULARY_NAME_2 + ':asd' }, + { value: undefined, authority: VOCABULARY_NAME_2 + ':fgh' }, + ]; + default: + return []; + } + }; + + const testItem1 = Object.assign(new Item(), { + allMetadata: () => allMetadata(METADATA_KEY_1), + }); + + const testItem2 = Object.assign(new Item(), { + allMetadata: () => allMetadata(METADATA_KEY_2), + }); + + const testField1: LayoutField = { + metadata: METADATA_KEY_1, + label: 'Knows Language', + rendering: 'valuepair.' + VOCABULARY_NAME_1, + fieldType: 'METADATA', + metadataGroup: null, + labelAsHeading: false, + valuesInline: false, + }; + + const testField2 = { + metadata: METADATA_KEY_2, + label: 'Type', + rendering: 'valuepair.' + VOCABULARY_NAME_2, + fieldType: 'METADATA', + metadataGroup: null, + labelAsHeading: false, + valuesInline: false, + }; + + const vocabularyEntriesMock = (vocabularyName, value) => { + switch (vocabularyName + '/' + value) { + case 'common_iso_languages/it': + return of('Italian'); + case 'common_iso_languages/en': + return of('English'); + case 'types/types:asd': + return of('Value'); + default: + return EMPTY; + } + }; + + const vocabularyServiceSpy = + jasmine.createSpyObj( + 'vocabularyService', + { getPublicVocabularyEntryByValue: EMPTY, getPublicVocabularyEntryByID: EMPTY }, + ); + + describe('when the vocabulary has no authority', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ValuepairComponent, DsDatePipe, + ], + providers: [ + { provide: VocabularyService, useValue: vocabularyServiceSpy }, + { provide: AuthService, useValue: authService }, + { provide: 'fieldProvider', useValue: testField1 }, + { provide: 'itemProvider', useValue: testItem1 }, + { provide: 'metadataValueProvider', useValue: { value: 'it', authority: null } }, + { provide: 'renderingSubTypeProvider', useValue: VOCABULARY_NAME_1 }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }).compileComponents(); + + vocabularyService = TestBed.inject(VocabularyService); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ValuepairComponent); + component = fixture.componentInstance; + vocabularyServiceSpy.getPublicVocabularyEntryByValue.and.callFake(vocabularyEntriesMock); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + + it('should ...', () => { + expect(vocabularyService.getPublicVocabularyEntryByValue).toHaveBeenCalledWith(VOCABULARY_NAME_1, 'it'); + }); + + + }); + + + describe('when the vocabulary has an authority', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ValuepairComponent, DsDatePipe, + ], + providers: [ + { provide: VocabularyService, useValue: vocabularyServiceSpy }, + { provide: AuthService, useValue: authService }, + { provide: 'fieldProvider', useValue: testField2 }, + { provide: 'itemProvider', useValue: testItem2 }, + { provide: 'metadataValueProvider', useValue: { value: undefined, authority: VOCABULARY_NAME_2 + ':asd' } }, + { provide: 'renderingSubTypeProvider', useValue: VOCABULARY_NAME_2 }, + { provide: 'tabNameProvider', useValue: '' }, + ], + }).compileComponents(); + + vocabularyService = TestBed.inject(VocabularyService); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ValuepairComponent); + component = fixture.componentInstance; + vocabularyServiceSpy.getPublicVocabularyEntryByID.and.callFake(vocabularyEntriesMock); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + + it('should ...', () => { + expect(vocabularyService.getPublicVocabularyEntryByID).toHaveBeenCalledWith(VOCABULARY_NAME_2, `${VOCABULARY_NAME_2}:asd`); + }); + + + }); + + describe('when the metadata value is a link', () => { + const linkValue = 'https://example.com'; + const testFieldLink: LayoutField = { + metadata: 'dc.identifier', + label: 'Identifier', + rendering: 'valuepair.' + VOCABULARY_NAME_2, + fieldType: 'METADATA', + metadataGroup: null, + labelAsHeading: false, + valuesInline: false, + }; + + const testItemLink = Object.assign(new Item(), { + allMetadata: () => [{ value: linkValue, authority: null }], + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ValuepairComponent, + DsDatePipe, + ], + providers: [ + { provide: VocabularyService, useValue: vocabularyServiceSpy }, + { provide: AuthService, useValue: authService }, + { provide: 'fieldProvider', useValue: testFieldLink }, + { provide: 'itemProvider', useValue: testItemLink }, + { provide: 'metadataValueProvider', useValue: { value: linkValue, authority: null } }, + { provide: 'renderingSubTypeProvider', useValue: '' }, // leave empty + { provide: 'tabNameProvider', useValue: '' }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ValuepairComponent); + component = fixture.componentInstance; + component.metadataValue = Object.assign(new MetadataValue(), { + value: linkValue, + }); + component.value$.next(linkValue); + + fixture.detectChanges(); + }); + + it('should detect that the value is a link', () => { + expect(component.isMetadataLink).toBeTrue(); + }); + + it('should render the value as an tag', () => { + const compiled = fixture.nativeElement as HTMLElement; + const linkEl = compiled.querySelector('a.text-value') as HTMLAnchorElement; + + expect(linkEl).toBeTruthy(); + expect(linkEl.href).toBe(linkValue + '/'); + expect(linkEl.textContent.trim()).toBe(linkValue); + }); + }); + + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.ts new file mode 100644 index 00000000000..4ce05955ea9 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/valuepair/valuepair.component.ts @@ -0,0 +1,92 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { AuthService } from '../../../../../../../core/auth/auth.service'; +import { LayoutField } from '../../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { + getFirstCompletedRemoteData, + getPaginatedListPayload, + getRemoteDataPayload, +} from '../../../../../../../core/shared/operators'; +import { VocabularyService } from '../../../../../../../core/submission/vocabularies/vocabulary.service'; +import { RenderingTypeValueModelComponent } from '../rendering-type-value.model'; + +/** + * This component renders the valuepair (value + display) metadata fields. + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'span[ds-valuepair]', + templateUrl: './valuepair.component.html', + styleUrls: ['./valuepair.component.scss'], + imports: [ + AsyncPipe, + ], +}) +export class ValuepairComponent extends RenderingTypeValueModelComponent implements OnInit { + + /** + * list of values + */ + value$: BehaviorSubject = new BehaviorSubject(null); + + /** + * Whether the value is a link + */ + + isMetadataLink: boolean; + + constructor( + @Inject('fieldProvider') public fieldProvider: LayoutField, + @Inject('itemProvider') public itemProvider: Item, + @Inject('metadataValueProvider') public metadataValueProvider: MetadataValue, + @Inject('renderingSubTypeProvider') public renderingSubTypeProvider: string, + @Inject('tabNameProvider') public tabNameProvider: string, + protected translateService: TranslateService, + protected vocabularyService: VocabularyService, + protected authService: AuthService, + ) { + super(fieldProvider, itemProvider, metadataValueProvider, renderingSubTypeProvider, tabNameProvider, translateService); + } + + ngOnInit(): void { + + const vocabularyName = this.renderingSubType; + const authority = this.metadataValue.authority ? this.metadataValue.authority.split(':') : undefined; + const isControlledVocabulary = authority?.length > 1 && authority[0] === vocabularyName; + + const vocabularyEntry$ = isControlledVocabulary ? + this.vocabularyService.getPublicVocabularyEntryByID(vocabularyName, this.metadataValue.authority) : + this.vocabularyService.getPublicVocabularyEntryByValue(vocabularyName, this.metadataValue.value); + + vocabularyEntry$.pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + getPaginatedListPayload(), + map((res) => res?.length > 0 ? res[0] : null), + map((res) => res?.display ?? this.metadataValue.value), + take(1), + ).subscribe(value => this.value$.next(value)); + + this.isMetadataLink = this.isLink(this.metadataValue.value); + } + + + isLink(input: string): boolean { + // check only values with protocol, if missing fix value in value-pair list + return input && (input.startsWith('http://') || input.startsWith('https://')); + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.html new file mode 100644 index 00000000000..d58ca9fc908 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.html @@ -0,0 +1,31 @@ +@if (metadataFieldRenderOptions) { +
+
+ @if (hasLabel) { + + + } + @if (!isStructured) { +
+ @for (metadataValue of metadataValues ; track metadataValue; let last = $last) { + + @if (field.valuesInline && !last) { +

+ } + } +
+ } + @if (isStructured) { +
+ +
+ } +
+
+} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.spec.ts new file mode 100644 index 00000000000..cd6fb2824c7 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.spec.ts @@ -0,0 +1,354 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DYNAMIC_FIELD_RENDERING_MAP } from '@dspace/config/app-config.interface'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { boxMetadata } from '@dspace/core/testing/box.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../core/shared/metadata.models'; +import { FieldRenderingType } from '../../rendering-types/field-rendering-type'; +import { layoutBoxesMap } from '../../rendering-types/metadata-box-rendering-map'; +import { TextComponent } from '../../rendering-types/text/text.component'; +import { MetadataContainerComponent } from './metadata-container.component'; +import { MetadataRenderComponent } from './metadata-render/metadata-render.component'; + +describe('MetadataContainerComponent', () => { + let component: MetadataContainerComponent; + let fixture: ComponentFixture; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'test item title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.title': [metadataValue], + 'dc.contributor.author': [ + { + value: 'Donohue, Tim', + }, + { + value: 'Surname, Name', + }, + ], + 'oairecerif.author.affiliation': [ + { + value: 'Duraspace', + }, + { + value: '4Science', + }, + ], + }, + uuid: 'test-item-uuid', + }, + ); + + const fieldMock = { + metadata: 'dc.title', + label: 'Preferred name', + rendering: null, + fieldType: 'METADATA', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + }; + + const fieldMockWithoutLabel = { + metadata: 'dc.title', + label: null, + rendering: null, + fieldType: 'METADATA', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + }; + + const fieldMockWithoutMetadata = { + metadata: 'dc.identifier', + label: 'Preferred name', + rendering: null, + fieldType: 'METADATA', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + }; + + const fieldStructuredMock = Object.assign({ + id: 1, + metadata: 'dc.contributor.author', + fieldType: 'METADATAGROUP', + label: 'Author(s)', + rendering: FieldRenderingType.TABLE, + style: 'container row', + styleLabel: 'test-group-style-label', + styleValue: 'test-group-style-value', + metadataGroup: { + leading: 'dc.contributor.author', + elements: [ + { + metadata: 'dc.contributor.author', + label: 'Author(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + }, + { + metadata: 'oairecerif.author.affiliation', + label: 'Affiliation(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'fw-bold col-0', + styleValue: 'col', + }, + ], + }, + }) as LayoutField; + + const bitstreamField = Object.assign({ + id: 1, + label: 'Field Label', + metadata: 'dc.identifier.doi', + rendering: FieldRenderingType.ATTACHMENT, + fieldType: 'BITSTREAM', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + bitstream: { + bundle: 'ORIGINAL', + metadataField: 'dc.type', + metadataValue: 'thumbnail', + }, + }); + + const bitstream1 = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1', + }); + + const mockBitstreamDataService = jasmine.createSpyObj('BitstreamDataService', { + findShowableBitstreamsByItem: jasmine.createSpy('findShowableBitstreamsByItem'), + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + MetadataContainerComponent, + TextComponent, + ], + providers: [ + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: DYNAMIC_FIELD_RENDERING_MAP, useValue: layoutBoxesMap }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(MetadataContainerComponent, { remove: { imports: [MetadataRenderComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataContainerComponent); + component = fixture.componentInstance; + component.item = testItem; + component.box = boxMetadata; + + mockBitstreamDataService.findShowableBitstreamsByItem.and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList([])), + ); + }); + + describe('When field rendering type is not structured', () => { + + beforeEach(() => { + component.field = fieldMock; + fixture.detectChanges(); + }); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render metadata properly', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-label')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('ds-metadata-render')); + expect(valueFound.length).toBe(1); + done(); + }); + }); + + describe('When field rendering type is structured', () => { + + beforeEach(() => { + component.field = fieldStructuredMock; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render metadata properly', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-group-style-label')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('ds-metadata-render')); + expect(valueFound.length).toBe(1); + done(); + }); + }); + + describe('When field has no label', () => { + + beforeEach(() => { + component.field = fieldMockWithoutLabel; + fixture.detectChanges(); + }); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render metadata properly', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-label')); + expect(spanValueFound.length).toBe(0); + + const valueFound = fixture.debugElement.queryAll(By.css('ds-metadata-render')); + expect(valueFound.length).toBe(1); + done(); + }); + }); + + describe('When item has not the field metadata', () => { + + beforeEach(() => { + component.field = fieldMockWithoutMetadata; + fixture.detectChanges(); + }); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render metadata properly', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-label')); + expect(spanValueFound.length).toBe(0); + + const valueFound = fixture.debugElement.queryAll(By.css('ds-metadata-render')); + expect(valueFound.length).toBe(0); + done(); + }); + }); + + describe('When field type is bitstream', () => { + beforeEach(() => { + component.field = bitstreamField; + }); + + describe('and item has no bitstream', () => { + + beforeEach(() => { + fixture.detectChanges(); + }); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not render metadata ', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-label')); + expect(spanValueFound.length).toBe(0); + + const valueFound = fixture.debugElement.queryAll(By.css('ds-metadata-render')); + expect(valueFound.length).toBe(0); + done(); + }); + }); + + describe('and item has bitstream', () => { + + beforeEach(() => { + mockBitstreamDataService.findShowableBitstreamsByItem.and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1]))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render metadata ', (done) => { + const spanValueFound = fixture.debugElement.queryAll(By.css('.test-style-label')); + expect(spanValueFound.length).toBe(1); + + const valueFound = fixture.debugElement.queryAll(By.css('ds-metadata-render')); + expect(valueFound.length).toBe(1); + done(); + }); + }); + + describe('and bitstream has metadata', () => { + beforeEach(() => { + component.field.bitstream.metadataField = 'metadataFieldTest'; + component.field.bitstream.metadataValue = 'metadataValueTest'; + fixture.detectChanges(); + }); + + it('should use the metadata in filters', () => { + expect(mockBitstreamDataService.findShowableBitstreamsByItem).toHaveBeenCalledWith( + testItem.uuid, + bitstreamField.bitstream.bundle, + [ { metadataName: 'metadataFieldTest', metadataValue: 'metadataValueTest' } ], + {}, + false, + ); + }); + }); + + describe('and bitstream doesnt have metadataValue', () => { + beforeEach(() => { + component.field.bitstream.metadataValue = undefined; + fixture.detectChanges(); + }); + + it('should use empty array in filters', () => { + expect(mockBitstreamDataService.findShowableBitstreamsByItem).toHaveBeenCalledWith( + testItem.uuid, + bitstreamField.bitstream.bundle, + [], // <--- empty array of filters, + {}, + false, + ); + }); + }); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.ts new file mode 100644 index 00000000000..5beed686c26 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-container.component.ts @@ -0,0 +1,202 @@ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + Input, + OnInit, +} from '@angular/core'; +import { DYNAMIC_FIELD_RENDERING_MAP } from '@dspace/config/app-config.interface'; +import { + BitstreamDataService, + MetadataFilter, +} from '@dspace/core/data/bitstream-data.service'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { + DynamicLayoutBox, + LayoutField, + LayoutFieldType, +} from '@dspace/core/layout/models/box.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { Item } from '@dspace/core/shared/item.model'; +import MetadataValue from '@dspace/core/shared/metadata.models'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { + hasValue, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../../../../../../environments/environment'; +import { FieldRenderingType } from '../../rendering-types/field-rendering-type'; +import { + computeRenderingFn, + getMetadataBoxFieldRenderOptionsFn, +} from '../../rendering-types/metadata-box.decorator'; +import { MetadataBoxFieldRenderOptions } from '../../rendering-types/rendering-type.model'; +import { MetadataRenderComponent } from './metadata-render/metadata-render.component'; + +@Component({ + selector: 'ds-metadata-container', + templateUrl: './metadata-container.component.html', + styleUrls: ['./metadata-container.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MetadataRenderComponent, + ], +}) +export class MetadataContainerComponent implements OnInit { + /** + * Current DSpace Item + */ + @Input() item: Item; + /** + * Current layout box + */ + @Input() box: DynamicLayoutBox; + /** + * The metadata field to render + */ + @Input() field: LayoutField; + /** + * The tab name + */ + @Input() tabName: string; + + /** + * The prefix used for box field label's i18n key + */ + readonly fieldI18nPrefix = 'layout.field.label'; + + /** + * A boolean representing if metadata rendering type is structured or not + */ + isStructured = false; + + /** + * The configuration of the rendering component to render + */ + metadataFieldRenderOptions: MetadataBoxFieldRenderOptions; + + protected readonly bitstreamDataService = inject(BitstreamDataService); + protected readonly translateService = inject(TranslateService); + protected readonly cd = inject(ChangeDetectorRef); + protected readonly layoutBoxesMap: Map = inject(DYNAMIC_FIELD_RENDERING_MAP); + + /** + * Returns all metadata values in the item + */ + get metadataValues(): MetadataValue[] { + return this.field.metadata ? this.item.allMetadata(this.field.metadata) : []; + } + + /** + * Returns true if the field has label, false otherwise + */ + get hasLabel(): boolean { + return hasValue(this.field.label); + } + + /** + * Returns a string representing the label of field if exists + */ + getLabel(): string { + if (this.field.fieldType === LayoutFieldType.BITSTREAM.toString()) { + return (hasValue(this.field.bitstream.metadataValue) ? + this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.item.entityType}.BITSTREAM[${this.field.bitstream.metadataValue}]`) : + this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.item.entityType}.BITSTREAM`) + ) ?? this.field.label; + } else { + return this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.item.entityType}.[${this.field.metadata}]`) ?? + this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.item.entityType}.${this.field.metadata}`) ?? // old syntax - do not use + this.getTranslationIfExists(`${this.fieldI18nPrefix}.[${this.field.metadata}]`) ?? + this.getTranslationIfExists(`${this.fieldI18nPrefix}.${this.field.label}`) ?? // old syntax - do not use + this.field.label; // the untranslated value from the dynamic layout + } + } + + /** + * Return the translated label, if exists, otherwise returns null + */ + getTranslationIfExists(key: string): string { + const translation: string = this.translateService.instant(key); + return translation !== key ? translation : null; + } + + /** + * Returns a string representing the style of field container if exists + */ + get containerStyle(): string { + return this.field.style; + } + + /** + * Returns a string representing the style of field label if exists, default value otherwise + */ + get labelStyle(): string { + const defaultCol = environment.layout.metadataBox.defaultMetadataLabelColStyle; + return (isNotEmpty(this.field.styleLabel) && this.field.styleLabel.includes('col')) + ? this.field.styleLabel : `${defaultCol} ${this.field.styleLabel}`; + } + + ngOnInit() { + const rendering = computeRenderingFn(this.field?.rendering); + if (this.field.fieldType === LayoutFieldType.BITSTREAM.toString() + && (rendering.toLocaleLowerCase() === FieldRenderingType.ATTACHMENT.toLocaleLowerCase() + || rendering.toLocaleLowerCase() === FieldRenderingType.ADVANCEDATTACHMENT.toLocaleLowerCase())) { + this.hasBitstream().pipe(take(1)).subscribe((hasBitstream: boolean) => { + if (hasBitstream) { + this.initRenderOptions(rendering); + } + }); + } else if (this.hasFieldMetadataComponent(this.field)) { + this.initRenderOptions(rendering); + } + } + + initRenderOptions(renderingType: string | FieldRenderingType): void { + this.metadataFieldRenderOptions = getMetadataBoxFieldRenderOptionsFn(this.layoutBoxesMap, renderingType); + this.isStructured = this.metadataFieldRenderOptions.structured; + this.cd.detectChanges(); + } + + hasBitstream(): Observable { + const filters: MetadataFilter[] = []; + if (isNotEmpty(this.field.bitstream.metadataValue)) { + filters.push({ + metadataName: this.field.bitstream.metadataField, + metadataValue: this.field.bitstream.metadataValue, + }); + } + return this.bitstreamDataService.findShowableBitstreamsByItem(this.item.uuid, this.field.bitstream.bundle, filters, {}, false) + .pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData>) => { + return response.hasSucceeded && response.payload.page.length > 0; + }), + ); + } + + hasFieldMetadataComponent(field: LayoutField) { + // if it is metadata-group and none of the nested metadata has values then don't generate the component + let existOneMetadataWithValue = false; + if (field.fieldType === LayoutFieldType.METADATAGROUP.toString()) { + field.metadataGroup.elements.forEach(el => { + if (this.item.metadata[el.metadata]) { + existOneMetadataWithValue = true; + } + }); + } + return (this.field.fieldType === LayoutFieldType.BITSTREAM.toString()) || + (field.fieldType === LayoutFieldType.METADATAGROUP.toString() && existOneMetadataWithValue) || + (field.fieldType === LayoutFieldType.METADATA.toString() && this.item.firstMetadataValue(field.metadata)); + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.html new file mode 100644 index 00000000000..88d018e99cb --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.html @@ -0,0 +1 @@ + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.scss new file mode 100644 index 00000000000..ab62eeaa243 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.scss @@ -0,0 +1,6 @@ +:host { + word-break: break-word; + &.auto-phrase { + word-break: auto-phrase; + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.spec.ts new file mode 100644 index 00000000000..30d192ff7f3 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.spec.ts @@ -0,0 +1,203 @@ +import { + ChangeDetectionStrategy, + Injector, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { DYNAMIC_FIELD_RENDERING_MAP } from '@dspace/config/app-config.interface'; +import { LayoutField } from '@dspace/core/layout/models/box.model'; +import { PLACEHOLDER_PARENT_METADATA } from '@dspace/core/shared/form/ds-dynamic-form-constants'; +import { Item } from '@dspace/core/shared/item.model'; +import { boxMetadata } from '@dspace/core/testing/box.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import MetadataValue from '../../../../../../../../core/shared/metadata.models'; +import { DsDatePipe } from '../../../../../../../pipes/ds-date.pipe'; +import { FieldRenderingType } from '../../../rendering-types/field-rendering-type'; +import { layoutBoxesMap } from '../../../rendering-types/metadata-box-rendering-map'; +import { TableComponent } from '../../../rendering-types/metadataGroup/table/table.component'; +import { TextComponent } from '../../../rendering-types/text/text.component'; +import { MetadataRenderComponent } from './metadata-render.component'; + +describe('MetadataRenderComponent', () => { + let component: MetadataRenderComponent; + let fixture: ComponentFixture; + + const metadataValue = Object.assign(new MetadataValue(), { + 'value': 'test item title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const metadataValueWithPlaceholder = Object.assign(new MetadataValue(), { + 'value': PLACEHOLDER_PARENT_METADATA, + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0, + }); + + const normalizedMetadataValue = Object.assign(new MetadataValue(), metadataValueWithPlaceholder,{ + 'value': '', + }); + + const testItem = Object.assign(new Item(), + { + type: 'item', + metadata: { + 'dc.title': [metadataValue], + 'dc.contributor.author': [ + { + value: 'Donohue, Tim', + }, + { + value: 'Surname, Name', + }, + ], + 'oairecerif.author.affiliation': [ + { + value: 'Duraspace', + }, + { + value: '4Science', + }, + ], + }, + uuid: 'test-item-uuid', + }, + ); + + const fieldMock = { + metadata: 'dc.title', + label: 'Preferred name', + rendering: null, + fieldType: 'METADATA', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + labelAsHeading: false, + valuesInline: true, + }; + + const fieldStructuredMock = Object.assign({ + id: 1, + metadata: 'dc.contributor.author', + fieldType: 'METADATAGROUP', + label: 'Author(s)', + rendering: FieldRenderingType.TABLE, + style: 'container row', + styleLabel: 'test-group-style-label', + styleValue: 'test-group-style-value', + metadataGroup: { + leading: 'dc.contributor.author', + elements: [ + { + metadata: 'dc.contributor.author', + label: 'Author(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'test-style-label', + styleValue: 'test-style-value', + }, + { + metadata: 'oairecerif.author.affiliation', + label: 'Affiliation(s)', + rendering: 'TEXT', + fieldType: 'METADATA', + style: null, + styleLabel: 'fw-bold col-0', + styleValue: 'col', + }, + ], + }, + }) as LayoutField; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + DsDatePipe, + MetadataRenderComponent, + TableComponent, + TextComponent, + ], + providers: [ + Injector, + { provide: 'tabNameProvider', useValue: '' }, + { provide: DYNAMIC_FIELD_RENDERING_MAP, useValue: layoutBoxesMap }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(TableComponent, { + set: { changeDetection: ChangeDetectionStrategy.OnPush }, + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataRenderComponent); + component = fixture.componentInstance; + component.item = testItem; + component.box = boxMetadata; + + }); + + describe('When field rendering type is not structured', () => { + beforeEach(() => { + component.field = fieldMock; + }); + + it('Should apply word-break style to host element', () => { + const hostElement = fixture.nativeElement; + expect(getComputedStyle(hostElement).wordBreak).toBe('break-word'); + }); + + describe('When field rendering type is not structured', () => { + beforeEach(() => { + component.metadataValue = metadataValue; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + }); + + describe('and metadata value has placeholder', () => { + beforeEach(() => { + component.metadataValue = metadataValueWithPlaceholder; + fixture.detectChanges(); + }); + + it('should normalize metadata value', () => { + + expect(component.metadataValue).toEqual(normalizedMetadataValue); + }); + }); + + }); + + describe('When field rendering type is structured', () => { + beforeEach(() => { + component.field = fieldStructuredMock; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.ts new file mode 100644 index 00000000000..efe31aa375a --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/metadata-container/metadata-render/metadata-render.component.ts @@ -0,0 +1,136 @@ +import { + Component, + ComponentRef, + inject, + Injector, + Input, + OnInit, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { DYNAMIC_FIELD_RENDERING_MAP } from '@dspace/config/app-config.interface'; +import { + DynamicLayoutBox, + LayoutField, +} from '@dspace/core/layout/models/box.model'; +import { PLACEHOLDER_PARENT_METADATA } from '@dspace/core/shared/form/ds-dynamic-form-constants'; +import { GenericConstructor } from '@dspace/core/shared/generic-constructor'; +import { Item } from '@dspace/core/shared/item.model'; +import MetadataValue from '@dspace/core/shared/metadata.models'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; + +import { DynamicLayoutLoaderDirective } from '../../../../../../../directives/dynamic-layout-loader.directive'; +import { FieldRenderingType } from '../../../rendering-types/field-rendering-type'; +import { + computeRenderingFn, + getMetadataBoxFieldRenderOptionsFn, +} from '../../../rendering-types/metadata-box.decorator'; +import { MetadataBoxFieldRenderOptions } from '../../../rendering-types/rendering-type.model'; + +@Component({ + selector: 'ds-metadata-render', + templateUrl: './metadata-render.component.html', + styleUrls: ['./metadata-render.component.scss'], + imports: [ + DynamicLayoutLoaderDirective, + ], +}) +export class MetadataRenderComponent implements OnInit { + + /** + * Current DSpace Item + */ + @Input() item: Item; + /** + * Current layout box + */ + @Input() box: DynamicLayoutBox; + /** + * The metadata field to render + */ + @Input() field: LayoutField; + /** + * The metadata value + */ + @Input() metadataValue: MetadataValue; + + /** + * The rendering subtype, if exists + * e.g. for type identifier.doi this property + * contains the subtype doi + */ + renderingSubType: string; + + /** + * Directive hook used to place the dynamic render component + */ + @ViewChild('metadataValue', { + static: true, + read: ViewContainerRef, + }) metadataValueViewRef: ViewContainerRef; + + protected readonly layoutBoxesMap: Map = inject(DYNAMIC_FIELD_RENDERING_MAP); + protected readonly injector: Injector = inject(Injector); + + ngOnInit(): void { + this.metadataValueViewRef.clear(); + this.renderingSubType = computeRenderingFn(this.field.rendering, true); + this.generateComponentRef(); + } + + /** + * Generate Component for metadata rendering + */ + computeComponent(): GenericConstructor { + const rendering = computeRenderingFn(this.field?.rendering); + const metadataFieldRenderOptions = getMetadataBoxFieldRenderOptionsFn(this.layoutBoxesMap, rendering); + return metadataFieldRenderOptions?.componentRef; + } + + /** + * Generate ComponentRef for metadata rendering + */ + generateComponentRef(): ComponentRef { + const component: GenericConstructor = this.computeComponent(); + return this.metadataValueViewRef.createComponent(component, { + injector: this.getComponentInjector(), + }); + } + + /** + * Generate Component Injector object + */ + getComponentInjector() { + const providers = [ + { provide: 'fieldProvider', useValue: this.field, deps: [] }, + { provide: 'itemProvider', useValue: this.item, deps: [] }, + { provide: 'renderingSubTypeProvider', useValue: this.renderingSubType, deps: [] }, + ]; + if (isNotEmpty(this.metadataValue)) { + this.metadataValue = this.normalizeMetadataValue(this.metadataValue); + providers.push({ provide: 'metadataValueProvider', useValue: (this.metadataValue as any), deps: [] }); + } + + return Injector.create({ + providers: providers, + parent: this.injector, + }); + } + + /** + * Normalize value to display. + * In case the value contains a PLACEHOLDER returns it as blank + * @param metadataValue + */ + private normalizeMetadataValue(metadataValue: MetadataValue): MetadataValue { + const value = metadataValue.value; + if (isNotEmpty(value) && value.includes(PLACEHOLDER_PARENT_METADATA)) { + return Object.assign(new MetadataValue(), metadataValue, { + value: '', + }); + } else { + return metadataValue; + } + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.html new file mode 100644 index 00000000000..c410b5ae673 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.html @@ -0,0 +1,10 @@ +@for (cell of row.cells; track cell) { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.spec.ts new file mode 100644 index 00000000000..9e8f9a72e20 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.spec.ts @@ -0,0 +1,67 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Item } from '@dspace/core/shared/item.model'; +import { metadataBoxConfigurationMock } from '@dspace/core/testing/box-configurations.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { TextComponent } from '../rendering-types/text/text.component'; +import { MetadataContainerComponent } from './metadata-container/metadata-container.component'; +import { RowComponent } from './row.component'; + +class TestItem { + firstMetadataValue(key: string): string { + return 'Item'; + } +} + +describe('RowComponent', () => { + let component: RowComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), RowComponent, + TextComponent], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(RowComponent, { remove: { imports: [MetadataContainerComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(RowComponent); + component = fixture.componentInstance; + component.item = new TestItem() as Item; + component.row = metadataBoxConfigurationMock.rows[0]; + fixture.detectChanges(); + }); + + + it('should render a div for each cell', () => { + const divValueFound = fixture.debugElement.queryAll(By.css('.metadata-cell')); + expect(divValueFound.length).toBe(2); + }); + + it('should render a metdata container for each field', () => { + let divValueFound = fixture.debugElement.query(By.css('.metadata-cell:nth-child(1)')); + let metadataFound = divValueFound.queryAll(By.css('ds-metadata-container')); + expect(metadataFound.length).toBe(1); + + divValueFound = fixture.debugElement.query(By.css('.metadata-cell:nth-child(2)')); + metadataFound = divValueFound.queryAll(By.css('ds-metadata-container')); + expect(metadataFound.length).toBe(5); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.ts new file mode 100644 index 00000000000..9b87f4ef532 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/row/row.component.ts @@ -0,0 +1,51 @@ + +import { + Component, + Input, +} from '@angular/core'; + +import { + DynamicLayoutBox, + LayoutField, + MetadataBoxCell, + MetadataBoxRow, +} from '../../../../../../core/layout/models/box.model'; +import { Item } from '../../../../../../core/shared/item.model'; +import { MetadataContainerComponent } from './metadata-container/metadata-container.component'; + +/** + * This component renders the rows of metadata boxes + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: '[ds-row]', + templateUrl: './row.component.html', + styleUrls: ['./row.component.scss'], + imports: [ + MetadataContainerComponent, + ], +}) +export class RowComponent { + + /** + * Current DSpace Item + */ + @Input() item: Item; + /** + * Current layout box + */ + @Input() box: DynamicLayoutBox; + /** + * Current row configuration + */ + @Input() row: MetadataBoxRow; + + trackUpdate(index, field: LayoutField) { + return field && field.metadata; + } + + trackCellUpdate(index, cell: MetadataBoxCell) { + return cell && cell.fields; + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.html new file mode 100644 index 00000000000..4774e65875a --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.html @@ -0,0 +1,11 @@ +@if (configuration) { + + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.scss new file mode 100644 index 00000000000..2af26e9d862 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.scss @@ -0,0 +1,7 @@ +:host{ + display:flex; + // flex:1; +} +ngb-accordion{ + flex-grow:1; +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.spec.ts new file mode 100644 index 00000000000..ae4b7ca5851 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.spec.ts @@ -0,0 +1,180 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { EPerson } from '@dspace/core/eperson/models/eperson.model'; +import { DynamicLayoutBox } from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { + EPersonMock, + EPersonMock2, +} from '@dspace/core/testing/eperson.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { of } from 'rxjs'; + +import MetadataValue from '../../../../../core/shared/metadata.models'; +import { ThemedConfigurationSearchPageComponent } from '../../../../../search-page/themed-configuration-search-page.component'; +import { DynamicLayoutRelationBoxComponent } from './dynamic-layout-relation-box.component'; + +describe('DynamicLayoutRelationBoxComponent', () => { + let component: DynamicLayoutRelationBoxComponent; + let fixture: ComponentFixture; + + const testItem = Object.assign(new Item(), { + id: '1234-65487-12354-1235', + bundles: of({}), + metadata: {}, + }); + + const testBox = Object.assign(new DynamicLayoutBox(), { + id: '1', + collapsed: false, + header: 'DynamicLayoutBox Header', + shortname: 'test-box', + configuration: { 'discovery-configuration': 'box-configuration-id' }, + }); + + const relationPublicationsBox = Object.assign(new DynamicLayoutBox(), { + id: '2', + collapsed: false, + header: 'Publications', + shortname: 'publications', + configuration: { 'discovery-configuration': 'RELATION.Person.researchoutputs' }, + }); + + const personItem = Object.assign(new Item(), { + id: '1234-65487-12354-1235', + bundles: of({}), + metadata: { + 'dspace.entity.type': [{ value: 'Person' }] as MetadataValue[], + 'dspace.object.owner': [{ value: 'not Owner', authority: EPersonMock2.id }] as MetadataValue[], + }, + }); + + const ownerItem = Object.assign(new Item(), { + id: '1234-65487-12354-1235', + bundles: of({}), + metadata: { + 'dspace.entity.type': [{ value: 'Person' }] as MetadataValue[], + 'dspace.object.owner': [{ value: 'Owner', authority: EPersonMock.id }] as MetadataValue[], + }, + }); + + const authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: of(null), + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + CommonModule, + DynamicLayoutRelationBoxComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: 'boxProvider', useValue: testBox }, + { provide: 'itemProvider', useValue: testItem }, + { provide: AuthService, useValue: authService }, + ], + }) + .overrideComponent(DynamicLayoutRelationBoxComponent, { remove: { imports: [ThemedConfigurationSearchPageComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutRelationBoxComponent); + component = fixture.componentInstance; + }); + + describe('When item is not a Person', () => { + beforeEach(() => { + component.box = testBox; + component.item = testItem; + fixture.detectChanges(); + }); + + it('should create DynamicLayoutRelationBoxComponent', () => { + expect(component).toBeDefined(); + }); + + it('should have set scope in searchFilter', () => { + expect(component.searchFilter).toContain('scope=' + testItem.id); + }); + + it('info message cannot be shown', fakeAsync(() => { + expect(component.showSearchResultNotice$).toBeObservable(cold('a', { a: false })); + })); + + it('info message has no value', fakeAsync(() => { + expect(component.searchResultNotice).toBeUndefined(); + })); + }); + + describe('When item is a Person', () => { + + describe('When relation-box of researchoutputs is shown', () => { + beforeEach(() => { + component.box = relationPublicationsBox; + }); + + describe('Whenever the personItem is the researcher profile of the logged user', () => { + beforeEach(() => { + (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(of({ id: EPersonMock.id } as EPerson)); + component.item = ownerItem; + fixture.detectChanges(); + }); + it('info message can be shown', fakeAsync(() => { + expect(component.showSearchResultNotice$).toBeObservable(cold('a', { a: true })); + })); + it('info message has value', fakeAsync(() => { + expect(component.searchResultNotice).not.toBeUndefined(); + })); + }); + + describe('Whenever the personItem is not the researcher profile of the logged user', () => { + beforeEach(() => { + (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(of({ id: 'fake-uuid' } as EPerson)); + component.item = personItem; + fixture.detectChanges(); + }); + it('info message cannot be shown', fakeAsync(() => { + expect(component.showSearchResultNotice$).toBeObservable(cold('a', { a: false })); + })); + it('info message has no value', fakeAsync(() => { + expect(component.searchResultNotice).not.toBeUndefined(); + })); + }); + + describe('no one is logged', () => { + beforeEach(() => { + (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(of(null as EPerson)); + fixture.detectChanges(); + }); + it('info message has no value', fakeAsync(() => { + // component.showSearchResultNotice$.subscribe(value => expect(value).toBeUndefined()); + expect(component.showSearchResultNotice$).toBeObservable(cold('a', { a: false })); + })); + it('info message has no value', fakeAsync(() => { + expect(component.searchResultNotice).toBeUndefined(); + })); + }); + + }); + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.ts new file mode 100644 index 00000000000..c5096945542 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/relation/dynamic-layout-relation-box.component.ts @@ -0,0 +1,105 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Inject, + OnInit, +} from '@angular/core'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { EPerson } from '@dspace/core/eperson/models/eperson.model'; +import { + DynamicLayoutBox, + RelationBoxConfiguration, +} from '@dspace/core/layout/models/box.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + shareReplay, +} from 'rxjs'; +import { + filter, + map, + take, +} from 'rxjs/operators'; + +import { ThemedConfigurationSearchPageComponent } from '../../../../../search-page/themed-configuration-search-page.component'; +import { DynamicLayoutBoxModelComponent } from '../../../../models/dynamic-layout-box-component.model'; + +@Component({ + selector: 'ds-dynamic-layout-search-box', + templateUrl: './dynamic-layout-relation-box.component.html', + styleUrls: ['./dynamic-layout-relation-box.component.scss'], + imports: [ + AsyncPipe, + ThemedConfigurationSearchPageComponent, + ], +}) +export class DynamicLayoutRelationBoxComponent extends DynamicLayoutBoxModelComponent implements OnInit { + + /** + * Filter used for set scope in discovery invocation + */ + searchFilter: string; + /** + * Name of configuration for this box + */ + configuration: string; + /** + * flag for enable/disable search bar + */ + searchEnabled = false; + + /** + * A boolean representing if to show or not the search notice + */ + showSearchResultNotice$: BehaviorSubject = new BehaviorSubject(false); + + /** + * The search notice message + */ + searchResultNotice: string; + + constructor( + protected authService: AuthService, + protected translateService: TranslateService, + @Inject('boxProvider') public boxProvider: DynamicLayoutBox, + @Inject('itemProvider') public itemProvider: Item, + ) { + super(translateService, boxProvider, itemProvider); + } + + ngOnInit(): void { + super.ngOnInit(); + + this.searchFilter = `scope=${this.item.id}`; + this.configuration = (this.box.configuration as RelationBoxConfiguration)['discovery-configuration']; + const isResearchOutputsConfiguration = + this.configuration?.endsWith('researchoutputs') || this.configuration?.endsWith('Publication'); + if (!this.isPersonEntity() || !isResearchOutputsConfiguration) { + this.showSearchResultNotice$.next(false); + } else { + this.isProfileOwner().pipe(take(1)).subscribe((result) => { + this.searchResultNotice = this.translateService.instant('manage.relationships.hidden-related-items-alert'); + this.showSearchResultNotice$.next(result); + }); + } + } + + protected getOwner(user: EPerson) { + return this.item.firstMetadataValue('dspace.object.owner', { authority: user.id }); + } + + protected isProfileOwner(): Observable { + return this.authService.getAuthenticatedUserFromStore().pipe( + filter(isNotEmpty), + map((user) => isNotEmpty(this.getOwner(user))), + shareReplay({ refCount: false, bufferSize: 1 }), + ); + } + + protected isPersonEntity(): boolean { + return isNotEmpty(this.item.firstMetadataValue('dspace.entity.type', { value: 'Person' })); + } +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.html new file mode 100644 index 00000000000..0a0b8e0e78e --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.html @@ -0,0 +1,22 @@ +@if (componentLoader) { + @if (box.container && !componentLoader.hasOwnContainer) { + + + + + + + + + + + } + @if (!box.container || componentLoader.hasOwnContainer) { +
+ +
+ } +} +@if (!componentLoader) { + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.scss new file mode 100644 index 00000000000..20ede91512b --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.scss @@ -0,0 +1,17 @@ +// TODO to remove the following when upgrading @ng-bootstrap +:host ::ng-deep .card:first-of-type { + border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color) !important; + border-bottom-left-radius: var(--bs-card-border-radius) !important; + border-bottom-right-radius: var(--bs-card-border-radius) !important; +} + +:host ::ng-deep .card-header button { + box-shadow: none !important; + width: 100%; +} + +:host ::ng-deep .card { + margin-bottom: var(--ds-submission-sections-margin-bottom); + overflow: unset; +} + diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.spec.ts new file mode 100644 index 00000000000..68aeefef296 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.spec.ts @@ -0,0 +1,104 @@ +import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Item } from '@dspace/core/shared/item.model'; +import { boxMetadata } from '@dspace/core/testing/box.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; +import { DynamicLayoutMetadataBoxComponent } from './boxes/metadata/dynamic-layout-metadata-box.component'; +import { DynamicLayoutBoxContainerComponent } from './dynamic-layout-box-container.component'; + +describe('DynamicLayoutBoxContainerComponent', () => { + let component: DynamicLayoutBoxContainerComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test', + }, + ], + 'dspace.entity.type': [ + { + language: null, + value: 'Person', + }, + ], + }, + }); + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + DynamicLayoutBoxContainerComponent, + ], + }).overrideComponent(DynamicLayoutBoxContainerComponent, { remove: { imports: [ThemedLoadingComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutBoxContainerComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + + component.box = boxMetadata; + component.item = mockItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + + describe('when inserting box', () => { + beforeEach(() => { + spyOn((component as any), 'getComponent').and.returnValue(DynamicLayoutMetadataBoxComponent); + component.box = boxMetadata; + component.item = mockItem; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should call get component', () => { + expect((component as any).getComponent).toHaveBeenCalled(); + }); + + it('should have object injector', () => { + expect((component as any).objectInjector).toBeTruthy(); + }); + + it('if box has container == false should not show accordion', () => { + component.box.container = false; + fixture.detectChanges(); + expect(de.query(By.css('ngb-accordion'))).toBeNull(); + }); + + it('if box has container == true should show accordion', () => { + component.box.container = true; + fixture.detectChanges(); + expect(de.query(By.css('ngb-accordion'))).toBeTruthy(); + }); + + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.ts new file mode 100644 index 00000000000..382aaceaa80 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/dynamic-layout-box-container.component.ts @@ -0,0 +1,148 @@ +import { NgComponentOutlet } from '@angular/common'; +import { + Component, + ComponentFactoryResolver, + Injector, + Input, + OnInit, +} from '@angular/core'; +import { DynamicLayoutBox } from '@dspace/core/layout/models/box.model'; +import { GenericConstructor } from '@dspace/core/shared/generic-constructor'; +import { Item } from '@dspace/core/shared/item.model'; +import { hasNoValue } from '@dspace/shared/utils/empty.util'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; + +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; +import { + DynamicLayoutBoxRenderOptions, + getDynamicLayoutBox, +} from '../../decorators/dynamic-layout-box.decorator'; +import { LayoutBox } from '../../enums/layout-box.enum'; + +/** + * Container component that wraps a single {@link DynamicLayoutBox} in an accordion panel. + * Dynamically resolves the correct box component (metadata, relation, collections, IIIF, versioning) + * using the {@link getDynamicLayoutBox} decorator map, and injects box/item providers + * so the child component can access its data. + */ +@Component({ + selector: 'ds-dynamic-layout-box-container', + templateUrl: './dynamic-layout-box-container.component.html', + styleUrls: ['./dynamic-layout-box-container.component.scss'], + imports: [ + NgbAccordionModule, + NgComponentOutlet, + ThemedLoadingComponent, + TranslateModule, + ], +}) +export class DynamicLayoutBoxContainerComponent implements OnInit { + + @Input() box: DynamicLayoutBox; + + /** + * Item that is being viewed + */ + @Input() item: Item; + + /** + * The tab name + */ + @Input() tabName: string; + + /** + * DynamicLayoutBoxRenderOptions reference of the box that will be created + */ + componentLoader: DynamicLayoutBoxRenderOptions; + + /** + * The prefix used for box header's i18n key + */ + boxI18nPrefix = 'layout.box.header.'; + + /** + * The i18n key used for box's header + */ + boxHeaderI18nKey: string; + + /** + * A generic i18n key used as fallback + */ + boxHeaderGenericI18nKey: string; + + /** + * Active tab utilized by accordion + */ + activeIds: string[] = []; + + /** + * Injector to inject a section component with the @Input parameters + * @type {Injector} + */ + public objectInjector: Injector; + + constructor( + private injector: Injector, + protected translateService: TranslateService, + protected componentFactoryResolver: ComponentFactoryResolver) { } + + ngOnInit(): void { + + this.objectInjector = Injector.create({ + providers: [ + { provide: 'boxProvider', useFactory: () => (this.box), deps: [] }, + { provide: 'itemProvider', useFactory: () => (this.item), deps: [] }, + { provide: 'tabNameProvider', useFactory: () => (this.tabName), deps: [] }, + ], + parent: this.injector, + }); + + this.componentLoader = this.getComponent(); + + this.boxHeaderI18nKey = this.boxI18nPrefix + this.box.entityType + '.' + this.box.shortname; + this.boxHeaderGenericI18nKey = this.boxI18nPrefix + this.box.shortname; + + if (hasNoValue(this.box.collapsed) || !this.box.collapsed) { + this.activeIds.push(this.box.shortname); + } + } + + /** + * Active tab utilized by accordion + */ + getComponent(): DynamicLayoutBoxRenderOptions { + return getDynamicLayoutBox(this.box.boxType as LayoutBox); + } + /** + * Get component reference to be inserted in the ngComponentOutlet + */ + getComponentRef(): GenericConstructor { + return this.componentLoader?.componentRef; + } + + /** + * get the translation for the i18n key + * @param key the i18n key + */ + getTranslation(key: string): string { + const value = this.translateService.instant(key); + return value === key ? null : value; + } + + /** + * Get box header to be inserted inside the accordion + */ + getBoxHeader(): string { + return this.getTranslation(this.boxHeaderI18nKey) ?? + this.getTranslation(this.boxHeaderGenericI18nKey) ?? + this.getTranslation(this.box.header) ?? + this.box.header ?? + ''; + } + + +} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.html b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.html new file mode 100644 index 00000000000..72226420a44 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.html @@ -0,0 +1,15 @@ +@if (!!tab) { +
+ @for (row of tab.rows; track row) { +
+ @for (cell of row.cells; track cell) { +
+ @for (box of cell.boxes; track box) { + + } +
+ } +
+ } +
+} diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.scss b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.spec.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.spec.ts new file mode 100644 index 00000000000..4d559556eb3 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.spec.ts @@ -0,0 +1,72 @@ +import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { leadingTabs } from '@dspace/core/testing/layout-tab.mocks'; + +import { DynamicLayoutBoxContainerComponent } from './dynamic-layout-box-container/dynamic-layout-box-container.component'; +import { DynamicLayoutMatrixComponent } from './dynamic-layout-matrix.component'; + +describe('DynamicLayoutMatrixComponent', () => { + let component: DynamicLayoutMatrixComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const tab = leadingTabs[0]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DynamicLayoutMatrixComponent], + }) + .overrideComponent(DynamicLayoutMatrixComponent, { remove: { imports: [DynamicLayoutBoxContainerComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutMatrixComponent); + de = fixture.debugElement; + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not show rows', () => { + expect(de.queryAll(By.css('.row')).length).toEqual(0); + }); + describe('after inserting selected tab', () => { + + beforeEach(() => { + component.tab = tab; + fixture.detectChanges(); + }); + + it('should have 2 rows', () => { + expect(de.queryAll(By.css('.row')).length).toEqual(2); + }); + + it('should have 1 row with test-class', () => { + expect(de.queryAll(By.css('.test-class')).length).toEqual(1); + }); + + it('should have 3 cells', () => { + expect(de.queryAll(By.css('.cell')).length).toEqual(3); + }); + + it('should have 2 col-md-6 cells', () => { + expect(de.queryAll(By.css('.col-md-6')).length).toEqual(2); + }); + + it('should have 1 col cell', () => { + expect(de.queryAll(By.css('.col')).length).toEqual(1); + }); + + it('should have 4 boxes', () => { + expect(de.queryAll(By.css('ds-dynamic-layout-box-container')).length).toEqual(4); + }); + + }); +}); diff --git a/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.ts b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.ts new file mode 100644 index 00000000000..bacd73023f0 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-matrix.component.ts @@ -0,0 +1,62 @@ + +import { + Component, + Input, +} from '@angular/core'; + +import { DynamicLayoutTab } from '../../core/layout/models/tab.model'; +import { Item } from '../../core/shared/item.model'; +import { DynamicLayoutBoxContainerComponent } from './dynamic-layout-box-container/dynamic-layout-box-container.component'; + +/** + * Component responsible for rendering the rows and cells of a tab's layout matrix. + * Each cell contains one or more boxes rendered via {@link DynamicLayoutBoxContainerComponent}. + * Applies Bootstrap grid classes based on configured styles. + */ +@Component({ + selector: 'ds-dynamic-layout-matrix', + templateUrl: './dynamic-layout-matrix.component.html', + styleUrls: ['./dynamic-layout-matrix.component.scss'], + imports: [ + DynamicLayoutBoxContainerComponent, + ], +}) +export class DynamicLayoutMatrixComponent { + + /** + * Tabs to render + */ + @Input() tab: DynamicLayoutTab; + + /** + * Tabs to render + */ + @Input() row; + + /** + * Item that is being viewed + */ + @Input() item: Item; + + /** + * A boolean representing if to use an internal padding for the cells + */ + @Input() showCellPadding = true; + + /** + * Check if style contains 'col' or 'col-x' + * @param style the style of the cell (a list of classes separated by space) + */ + hasColClass(style) { + return style?.split(' ').filter((c) => (c === 'col' || c.startsWith('col-'))).length > 0; + } + + /** + * Check if style contains 'row' + * @param style the style of the row (a list of classes separated by space) + */ + hasRowClass(style) { + return style?.split(' ').filter((r) => (r === 'row')).length > 0; + } + +} diff --git a/src/app/dynamic-layout/dynamic-layout.component.html b/src/app/dynamic-layout/dynamic-layout.component.html new file mode 100644 index 00000000000..9becb2585f2 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout.component.html @@ -0,0 +1,24 @@ +
+ @if (!!tabs$) { + + @if ((hasLeadingTab$ | async) && !!leadingTabs && leadingTabs.length > 0) { + + } + + } + + @if (!!tabs$) { + + @if (!!loaderTabs && loaderTabs.length > 0) { + + } + + } +
diff --git a/src/app/dynamic-layout/dynamic-layout.component.scss b/src/app/dynamic-layout/dynamic-layout.component.scss new file mode 100644 index 00000000000..fdd2a48aa35 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout.component.scss @@ -0,0 +1,3 @@ +.dynamic-layout { + margin-top: -1rem; +} diff --git a/src/app/dynamic-layout/dynamic-layout.component.spec.ts b/src/app/dynamic-layout/dynamic-layout.component.spec.ts new file mode 100644 index 00000000000..0a2a1ff283a --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout.component.spec.ts @@ -0,0 +1,134 @@ +import { + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { + bothTabs, + leadingTabs, + loaderTabs, +} from '@dspace/core/testing/layout-tab.mocks'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { TabDataService } from '../core/layout/tab-data.service'; +import { Item } from '../core/shared/item.model'; +import { DynamicLayoutComponent } from './dynamic-layout.component'; +import { DynamicLayoutLeadingComponent } from './dynamic-layout-leading/dynamic-layout-leading.component'; +import { DynamicLayoutLoaderComponent } from './dynamic-layout-loader/dynamic-layout-loader.component'; + +const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test', + }, + ], + 'dspace.entity.type': [ + { + language: null, + value: 'Person', + }, + ], + }, +}); + +const tabDataServiceMock: any = jasmine.createSpyObj('TabDataService', { + findByItem: of(leadingTabs), +}); +const route = { + data: of({ tabs: createSuccessfulRemoteDataObject(createPaginatedList(leadingTabs)) }), +}; + +describe('DynamicLayoutComponent', () => { + let component: DynamicLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, DynamicLayoutComponent], + providers: [ + { provide: TabDataService, useValue: tabDataServiceMock }, + { provide: ChangeDetectorRef, useValue: {} }, + { provide: ActivatedRoute, useValue: route }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(DynamicLayoutComponent, { remove: { imports: [DynamicLayoutLeadingComponent, DynamicLayoutLoaderComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DynamicLayoutComponent); + component = fixture.componentInstance; + component.item = mockItem; + tabDataServiceMock.findByItem.and.returnValue(of(leadingTabs)); + + component.tabs$ = of(leadingTabs); + component.leadingTabs$ = of(leadingTabs); + component.loaderTabs$ = of([]); + + component.hasLeadingTab$.next(true); + fixture.detectChanges(); + }); + + describe('When the component is rendered', () => { + + it('it should show only ds-dynamic-layout-leading when only leading tabs', fakeAsync(() => { + component.tabs$ = of(leadingTabs); + component.leadingTabs$ = of(leadingTabs); + component.loaderTabs$ = of([]); + fixture.detectChanges(); + tick(); // Simulate the passage of time to ensure the DOM is updated + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-leading'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-loader'))).toBeNull(); + })); + + it('it should show only ds-dynamic-layout-loader when only loader tabs', fakeAsync(() => { + component.tabs$ = of(loaderTabs); + component.leadingTabs$ = of([]); + component.loaderTabs$ = of(loaderTabs); + fixture.detectChanges(); + tick(); // Simulate the passage of time to ensure the DOM is updated + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-loader'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-leading'))).toBeNull(); + })); + + it('it should show both when both types of tabs', fakeAsync(() => { + component.tabs$ = of(bothTabs); + component.leadingTabs$ = of(leadingTabs); + component.loaderTabs$ = of(loaderTabs); + fixture.detectChanges(); + tick(); // Simulate the passage of time to ensure the DOM is updated + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-loader'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('ds-dynamic-layout-leading'))).toBeTruthy(); + })); + + }); + +}); diff --git a/src/app/dynamic-layout/dynamic-layout.component.ts b/src/app/dynamic-layout/dynamic-layout.component.ts new file mode 100644 index 00000000000..6f46f27c911 --- /dev/null +++ b/src/app/dynamic-layout/dynamic-layout.component.ts @@ -0,0 +1,160 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; +import { + filter, + map, + take, +} from 'rxjs/operators'; + +import { RemoteData } from '../core/data/remote-data'; +import { DynamicLayoutTab } from '../core/layout/models/tab.model'; +import { TabDataService } from '../core/layout/tab-data.service'; +import { Item } from '../core/shared/item.model'; +import { + getFirstSucceededRemoteData, + getPaginatedListPayload, + getRemoteDataPayload, +} from '../core/shared/operators'; +import { VarDirective } from '../shared/utils/var.directive'; +import { PaginatedList } from './../core/data/paginated-list.model'; +import { DynamicLayoutLeadingComponent } from './dynamic-layout-leading/dynamic-layout-leading.component'; +import { DynamicLayoutLoaderComponent } from './dynamic-layout-loader/dynamic-layout-loader.component'; + +/** + * Top-level orchestrator component for the dynamic item page layout system. + * + * Resolves layout tabs (from route data or an input observable) and splits them into + * leading tabs (rendered above the main content via {@link DynamicLayoutLeadingComponent}) + * and loader tabs (rendered via {@link DynamicLayoutLoaderComponent} which picks the + * appropriate orientation — horizontal or vertical — based on configuration). + */ +@Component({ + selector: 'ds-dynamic-layout', + templateUrl: './dynamic-layout.component.html', + styleUrls: ['./dynamic-layout.component.scss'], + imports: [ + AsyncPipe, + DynamicLayoutLeadingComponent, + DynamicLayoutLoaderComponent, + VarDirective, + ], +}) +export class DynamicLayoutComponent implements OnInit { + + /** + * DSpace Item to render + */ + @Input() item: Item; + + /** + * DSpace dataTabs coming as Input for specific item + */ + @Input() dataTabs$: Observable>>; + + /** + * A boolean representing if to show context menu or not + */ + @Input() showContextMenu = true; + + /** + * Get tabs for the specific item + */ + tabs$: Observable; + + /** + * Get loader tabs for the specific item + */ + loaderTabs$: Observable; + + /** + * Get leading for the specific item + */ + leadingTabs$: Observable; + + /** + * Get if has leading tabs + */ + hasLeadingTab$: BehaviorSubject = new BehaviorSubject(false); + + constructor(private tabService: TabDataService, private router: ActivatedRoute) { + } + + /** + * Get tabs for the specific item + */ + ngOnInit(): void { + + if (this.dataTabs$) { + this.tabs$ = this.dataTabs$.pipe( + map((res: any) => { + return res.payload.page; + }), + ); + } else { + this.tabs$ = this.router.data.pipe( + map((res: any) => { + return res.tabs.payload.page; + }), + ); + } + this.leadingTabs$ = this.getLeadingTabs(); + this.loaderTabs$ = this.getLoaderTabs(); + + this.hasLeadingTab().pipe( + filter((result) => isNotEmpty(result)), + take(1), + ).subscribe((result) => { + this.hasLeadingTab$.next(result); + }); + } + + /** + * Get tabs for the specific item + */ + getTabsByItem(): Observable { + // Since there is no API ready + return this.tabService.findByItem(this.item.uuid, true).pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + getPaginatedListPayload(), + ); + } + + /** + * Get tabs for the leading component where parameter leading is true b + */ + getLeadingTabs(): Observable { + return this.tabs$.pipe( + map((tabs: DynamicLayoutTab[]) => tabs.filter(tab => tab.leading)), + ); + } + + /** + * Get tabs for the loader component where parameter leading is false + */ + getLoaderTabs(): Observable { + return this.tabs$.pipe( + map((tabs: DynamicLayoutTab[]) => tabs.filter(tab => !tab.leading)), + ); + } + + /** + * Return a boolean representing if there is a leading tab configured + */ + hasLeadingTab(): Observable { + return this.getLeadingTabs().pipe( + map((tabs: DynamicLayoutTab[]) => tabs && tabs.length > 0), + ); + } + +} diff --git a/src/app/dynamic-layout/enums/entity-type.enum.ts b/src/app/dynamic-layout/enums/entity-type.enum.ts new file mode 100644 index 00000000000..d4b4c506a29 --- /dev/null +++ b/src/app/dynamic-layout/enums/entity-type.enum.ts @@ -0,0 +1,9 @@ +/** + * Enum of DSpace object entity types used in dynamic layout rendering. + */ +export enum EntityTypeEnum { + Item = 'item', + Collection = 'collection', + Bitstream = 'bitstream', + Community = 'community', +} diff --git a/src/app/dynamic-layout/enums/layout-box.enum.ts b/src/app/dynamic-layout/enums/layout-box.enum.ts new file mode 100644 index 00000000000..d0dfbf15d96 --- /dev/null +++ b/src/app/dynamic-layout/enums/layout-box.enum.ts @@ -0,0 +1,17 @@ +/** + * This enum contains the boxes types + * It is used to determine which component to use for render a specific box. + * To overwrite a box its boxType must be entered as a value. + */ +export enum LayoutBox { + METADATA = 'METADATA', + METRICS = 'METRICS', + SEARCH = 'SEARCH', + RELATION = 'RELATION', + ORCID_SYNC_SETTINGS = 'ORCID_SYNC_SETTINGS', + ORCID_AUTHORIZATIONS = 'ORCID_AUTHORIZATIONS', + ORCID_SYNC_QUEUE = 'ORCID_SYNC_QUEUE', + IIIFVIEWER = 'IIIFVIEWER', + COLLECTIONS = 'COLLECTIONS', + VERSIONING = 'VERSIONING', +} diff --git a/src/app/dynamic-layout/enums/layout-page.enum.ts b/src/app/dynamic-layout/enums/layout-page.enum.ts new file mode 100644 index 00000000000..ffa0e1afcae --- /dev/null +++ b/src/app/dynamic-layout/enums/layout-page.enum.ts @@ -0,0 +1,15 @@ +/** + * This enum contains the types of layout pages. + * It is used to determine which component to use for render a specific item. + * To overwrite the layout page of a specific item its entityType must be entered as a value + */ + +export enum LayoutPage { + VERTICAL = 'vertical', + HORIZONTAL = 'horizontal' +} + +/** + * The default page is always applied by the component resolver as a fallback value; + */ +export const DEFAULT_LAYOUT_PAGE = LayoutPage.VERTICAL; diff --git a/src/app/dynamic-layout/enums/layout-tab.enum.ts b/src/app/dynamic-layout/enums/layout-tab.enum.ts new file mode 100644 index 00000000000..5f63e37edc9 --- /dev/null +++ b/src/app/dynamic-layout/enums/layout-tab.enum.ts @@ -0,0 +1,14 @@ +/** + * This enum contains the types of layout tabs. + * It is used to determine which component to use for render a tab of a specific item. + * To overwrite a specific tab its shortname must be entered as a value. + */ +export enum LayoutTab { + DEFAULT = 'defaultLayoutTab', + ORCID = 'orcid' +} + +/** + * The default tab is applied by the component resolver as a fallback value; + */ +export const DEFAULT_LAYOUT_TAB = LayoutTab.DEFAULT; diff --git a/src/app/dynamic-layout/models/dynamic-layout-box-component.model.ts b/src/app/dynamic-layout/models/dynamic-layout-box-component.model.ts new file mode 100644 index 00000000000..d142eccca9a --- /dev/null +++ b/src/app/dynamic-layout/models/dynamic-layout-box-component.model.ts @@ -0,0 +1,88 @@ +import { + Component, + EventEmitter, + Inject, + Input, + OnInit, + Output, +} from '@angular/core'; +import { + hasValue, + isEmpty, +} from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; + +import { DynamicLayoutBox } from '../../core/layout/models/box.model'; +import { Item } from '../../core/shared/item.model'; +import { DynamicLayoutPageModelComponent } from './dynamic-layout-page-component.model'; + +/** + * This class is a model to be extended for creating custom layouts for boxes + */ +@Component({ + template: '', +}) +export abstract class DynamicLayoutBoxModelComponent extends DynamicLayoutPageModelComponent implements OnInit { + + /** + * DynamicLayoutBox. + */ + @Input() box: DynamicLayoutBox; + + /** + * Emit a refresh box request from within the DynamicLayoutBox. + */ + @Output() refreshBox: EventEmitter = new EventEmitter(); + + /** + * Emit a refresh tab request from within the DynamicLayoutBox. + */ + @Output() refreshTab: EventEmitter = new EventEmitter(); + + activeIds: string[] = []; + + /** + * The prefix used for box header's i18n key + */ + boxI18nPrefix = 'layout.box.header.'; + + /** + * The i18n key used for box's header + */ + boxHeaderI18nKey = ''; + + protected constructor( + protected translateService: TranslateService, + @Inject('boxProvider') public boxProvider: DynamicLayoutBox, + @Inject('itemProvider') public itemProvider: Item, + ) { + super(); + this.box = boxProvider; + this.item = itemProvider; + } + + /** + * Check if the current box is collapsed or not + */ + ngOnInit(): void { + this.boxHeaderI18nKey = this.boxI18nPrefix + this.box.shortname; + + if (!hasValue(this.box.collapsed) || !this.box.collapsed) { + this.activeIds.push(this.box.shortname); + } + + } + + /** + * Get box header to be inserted inside the accordion + */ + getBoxHeader(): string { + const header: string = isEmpty(this.boxHeaderI18nKey) ? null : this.translateService.instant(this.boxHeaderI18nKey); + if (isEmpty(header) || header === this.boxHeaderI18nKey) { + // if translation does not exist return the value present in the header property + return this.translateService.instant(this.box.header); + } else { + return header; + } + } +} diff --git a/src/app/dynamic-layout/models/dynamic-layout-metadata-link-value.model.ts b/src/app/dynamic-layout/models/dynamic-layout-metadata-link-value.model.ts new file mode 100644 index 00000000000..8d62a65a51f --- /dev/null +++ b/src/app/dynamic-layout/models/dynamic-layout-metadata-link-value.model.ts @@ -0,0 +1,10 @@ +export interface MetadataLinkValue { + /** + * value of href anchor + */ + href: string; + /** + * text to show in the anchor + */ + text: string; +} diff --git a/src/app/dynamic-layout/models/dynamic-layout-page-component.model.ts b/src/app/dynamic-layout/models/dynamic-layout-page-component.model.ts new file mode 100644 index 00000000000..5b338861f92 --- /dev/null +++ b/src/app/dynamic-layout/models/dynamic-layout-page-component.model.ts @@ -0,0 +1,19 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { Item } from '../../core/shared/item.model'; + +/** + * This class is a model to be extended for creating custom layouts for pages + */ +@Component({ + template: '', +}) +export abstract class DynamicLayoutPageModelComponent { + /** + * DSpace Item to render + */ + @Input() item: Item; +} diff --git a/src/app/dynamic-layout/models/dynamic-layout-tab-component.model.ts b/src/app/dynamic-layout/models/dynamic-layout-tab-component.model.ts new file mode 100644 index 00000000000..6cb0cc1de10 --- /dev/null +++ b/src/app/dynamic-layout/models/dynamic-layout-tab-component.model.ts @@ -0,0 +1,28 @@ +import { + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; + +import { DynamicLayoutTab } from '../../core/layout/models/tab.model'; +import { DynamicLayoutPageModelComponent } from './dynamic-layout-page-component.model'; + +/** + * This class is a model to be extended for creating custom layouts for tabs + */ +@Component({ + template: '', +}) +export abstract class DynamicLayoutTabModelComponent extends DynamicLayoutPageModelComponent { + /** + * DynamicLayoutTab + */ + @Input() tab: DynamicLayoutTab; + + /** + * Emit a refresh tab request from within the DynamicLayoutTab. + */ + @Output() refreshTab: EventEmitter = new EventEmitter(); + +} diff --git a/src/app/dynamic-layout/pipes/ds-date.pipe.spec.ts b/src/app/dynamic-layout/pipes/ds-date.pipe.spec.ts new file mode 100644 index 00000000000..4da3980a148 --- /dev/null +++ b/src/app/dynamic-layout/pipes/ds-date.pipe.spec.ts @@ -0,0 +1,57 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { DsDatePipe } from './ds-date.pipe'; + +describe('DsDatePipe', () => { + + const cdrInstance = Object.assign({ + detectChanges: () => { /***/ + }, + markForCheck: () => { /***/ + }, + }); + + const localeServiceInstance = Object.assign({ + getCurrentLanguageCode: () => of('en'), + }); + + const date = '2020-08-24'; + const parsedDate = 'August 24, 2020'; + + let pipe: DsDatePipe; + + beforeEach(() => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + pipe = new DsDatePipe(cdrInstance as any, localeServiceInstance as any); + }); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('Should transform the given date and time', waitForAsync(() => { + expect(pipe.transform(`${date}T11:22:33Z`)).toEqual(parsedDate); + })); + + it('Should transform the given date (yyyy-MM-DD)', waitForAsync(() => { + expect(pipe.transform(date)).toEqual(parsedDate); + })); + + it('Should transform the given date (yyyy-MM)', waitForAsync(() => { + expect(pipe.transform('2020-08')).toEqual('August 2020'); + })); + + it('Should transform the given date (YYYY)', waitForAsync(() => { + expect(pipe.transform('2020')).toEqual('2020'); + })); + + it('Should not transform invalid dates', waitForAsync(() => { + expect(pipe.transform('ABCDE')).toEqual('ABCDE'); + })); +}); diff --git a/src/app/dynamic-layout/pipes/ds-date.pipe.ts b/src/app/dynamic-layout/pipes/ds-date.pipe.ts new file mode 100644 index 00000000000..e9c4105b287 --- /dev/null +++ b/src/app/dynamic-layout/pipes/ds-date.pipe.ts @@ -0,0 +1,42 @@ +import { AsyncPipe } from '@angular/common'; +import { + ChangeDetectorRef, + OnDestroy, + Pipe, + PipeTransform, +} from '@angular/core'; +import { + isValidDate, + localeDate, +} from '@dspace/shared/utils/date.util'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { LocaleService } from '../../core/locale/locale.service'; + +@Pipe({ + name: 'dsDate', + pure: true, +}) +export class DsDatePipe implements PipeTransform, OnDestroy { + + private asyncPipe: AsyncPipe; + + constructor( + cdr: ChangeDetectorRef, + private localeService: LocaleService, + ) { + this.asyncPipe = new AsyncPipe(cdr); + } + + transform(value: string): string | null { + const formatted$: Observable = this.localeService.getCurrentLanguageCode().pipe( + map((locale: string) => (isValidDate(value) ? localeDate(value, locale) : value)), + ); + return this.asyncPipe.transform(formatted$) as string | null; + } + + ngOnDestroy(): void { + this.asyncPipe.ngOnDestroy(); + } +} diff --git a/src/app/dynamic-layout/services/resolver-strategy.service.spec.ts b/src/app/dynamic-layout/services/resolver-strategy.service.spec.ts new file mode 100644 index 00000000000..ef83b144103 --- /dev/null +++ b/src/app/dynamic-layout/services/resolver-strategy.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; + +import { ResolverStrategyService } from './resolver-strategy.service'; + +describe('ResolverStrategyService', () => { + let service: ResolverStrategyService; + + const httpLink = 'http://rest.api/item'; + const httpsLink = 'https://rest.api/item'; + const ftpLink = 'ftp://rest.api/item'; + const ftpsLink = 'ftps://rest.api/item'; + const noProtocolLink = 'rest.api/item'; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = new ResolverStrategyService(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should check if given value is a link', () => { + expect(service.checkLink(httpLink)).toEqual(true); + expect(service.checkLink(httpsLink)).toEqual(true); + expect(service.checkLink(ftpLink)).toEqual(true); + expect(service.checkLink(ftpsLink)).toEqual(true); + expect(service.checkLink(noProtocolLink)).toEqual(false); + }); + + it('should return base URL if exists given URN', () => { + expect(service.getBaseUrl('doi')).toBeTruthy(); + expect(service.getBaseUrl('FAKEURN')).toBeFalsy(); + }); +}); diff --git a/src/app/dynamic-layout/services/resolver-strategy.service.ts b/src/app/dynamic-layout/services/resolver-strategy.service.ts new file mode 100644 index 00000000000..7b6b03e373b --- /dev/null +++ b/src/app/dynamic-layout/services/resolver-strategy.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { hasValue } from '@dspace/shared/utils/empty.util'; + +import { environment } from '../../../environments/environment'; + +/** + * Service that maps URN (Uniform Resource Name) identifiers (e.g., DOI, HANDLE, SCOPUS) + * to their resolver base URLs, as defined in the environment configuration. + * Also provides utility methods to detect whether a metadata value is a link. + */ +@Injectable({ + providedIn: 'root', +}) +export class ResolverStrategyService { + + /** + * List of managed URN + */ + private urn2baseurl: Map; + + constructor() { + /** + * Set the list of managed URN + */ + this.urn2baseurl = new Map(); + const urnList = environment.layout.urn; + if (hasValue(urnList)) { + for (const urn of urnList) { + this.urn2baseurl.set(urn.name, urn.baseUrl); + } + } + } + + /** + * Returns the list of managed URN + */ + get managedUrn(): IterableIterator { + return this.urn2baseurl.keys(); + } + + /** + * Returns base url for a given URN + * @param key string that represents the URN (DOI, HANDLE, EMAIL) + */ + getBaseUrl(key: string): string { + return this.urn2baseurl.get(key); + } + + /** + * Check if the give parameter is a link + * @param value metadata value + */ + checkLink(value: string): boolean { + return hasValue(value) && ( + value.toLowerCase().startsWith('http://') || + value.toLowerCase().startsWith('https://') || + value.toLowerCase().startsWith('ftp://') || + value.toLowerCase().startsWith('ftps://') ) ; + } +} diff --git a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts index b91cf69534c..ffedea06fc5 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts @@ -11,7 +11,7 @@ import { import { By } from '@angular/platform-browser'; import { RouterLink } from '@angular/router'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts index d545f8a5897..4c526dfb71f 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts @@ -11,7 +11,7 @@ import { import { By } from '@angular/platform-browser'; import { RouterLink } from '@angular/router'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.spec.ts b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.spec.ts index 32f0bd0c82f..9220629ccaa 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.spec.ts @@ -11,7 +11,7 @@ import { By } from '@angular/platform-browser'; import { RouterLink } from '@angular/router'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { DSONameServiceMock } from '@dspace/core/testing/dso-name.service.mock'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts index a0ab3c749e2..0e5d7d0f617 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts @@ -5,7 +5,7 @@ import { } from '@angular/core'; import { Context } from '@dspace/core/shared/context.model'; import { ExternalSourceEntry } from '@dspace/core/shared/external-source-entry.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Metadata } from '@dspace/core/shared/metadata.utils'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts index 710901a5245..fc123bc3fdd 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts @@ -13,7 +13,7 @@ import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { ItemDataService } from '@dspace/core/data/item-data.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 MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts index 93a793b6c6b..9538c18bcf2 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts @@ -16,7 +16,7 @@ import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { ItemDataService } from '@dspace/core/data/item-data.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 MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts b/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts index 9e5a4ede552..aeab13241f6 100644 --- a/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts +++ b/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts @@ -12,7 +12,7 @@ import { AuthService } from '@dspace/core/auth/auth.service'; import { AuthMethod } from '@dspace/core/auth/models/auth.method'; import { AuthMethodType } from '@dspace/core/auth/models/auth.method-type'; import { AuthRegistrationType } from '@dspace/core/auth/models/auth.registration-type'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Registration } from '@dspace/core/shared/registration.model'; import { AuthServiceMock } from '@dspace/core/testing/auth.service.mock'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/external-log-in/models/registration-data.mock.model.ts b/src/app/external-log-in/models/registration-data.mock.model.ts index 23b26ca9714..44257fd2a08 100644 --- a/src/app/external-log-in/models/registration-data.mock.model.ts +++ b/src/app/external-log-in/models/registration-data.mock.model.ts @@ -1,5 +1,5 @@ import { AuthMethodType } from '@dspace/core/auth/models/auth.method-type'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Registration } from '@dspace/core/shared/registration.model'; export const mockRegistrationDataModel: Registration = Object.assign( diff --git a/src/app/init.service.ts b/src/app/init.service.ts index 7838a21706d..a8edc07e4c0 100644 --- a/src/app/init.service.ts +++ b/src/app/init.service.ts @@ -18,6 +18,7 @@ import { import { APP_CONFIG, AppConfig, + DYNAMIC_FIELD_RENDERING_MAP, } from '@dspace/config/app-config.interface'; import { CheckAuthenticationTokenAction } from '@dspace/core/auth/auth.actions'; import { isAuthenticationBlocking } from '@dspace/core/auth/selectors'; @@ -42,6 +43,7 @@ import { import { environment } from '../environments/environment'; import { AppState } from './app.reducer'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; +import { layoutBoxesMap } from './dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/metadata-box-rendering-map'; import { dsDynamicFormControlMapFn } from './shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn'; import { MenuService } from './shared/menu/menu.service'; import { MenuProviderService } from './shared/menu/menu-provider.service'; @@ -117,6 +119,10 @@ export abstract class InitService { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn, }, + { + provide: DYNAMIC_FIELD_RENDERING_MAP, + useValue: layoutBoxesMap, + }, ]; } diff --git a/src/app/item-page/dynamic-item-page-tab.resolver.spec.ts b/src/app/item-page/dynamic-item-page-tab.resolver.spec.ts new file mode 100644 index 00000000000..8521e8939fb --- /dev/null +++ b/src/app/item-page/dynamic-item-page-tab.resolver.spec.ts @@ -0,0 +1,207 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + tabDetailsTest, + tabFundingsTest, + tabPublicationsTest, +} from '@dspace/core/testing/layout-tab.mocks'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '@dspace/core/utilities/remote-data.utils'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { ItemDataService } from '../core/data/item-data.service'; +import { PaginatedList } from '../core/data/paginated-list.model'; +import { RemoteData } from '../core/data/remote-data'; +import { DynamicLayoutTab } from '../core/layout/models/tab.model'; +import { TabDataService } from '../core/layout/tab-data.service'; +import { HardRedirectService } from '../core/services/hard-redirect.service'; +import { Item } from '../core/shared/item.model'; +import { dynamicItemPageTabResolver } from './dynamic-item-page-tab.resolver'; + +describe('DynamicItemPageTabResolver', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([{ + path: 'entities/:entity-type/:id/:tab', + component: {} as any, + }])], + }); + }); + + describe('dynamicItemPageTabResolver', () => { + let itemService: jasmine.SpyObj; + let tabService: jasmine.SpyObj; + let hardRedirectService: jasmine.SpyObj; + let router: Router; + + const uuid = '1234-65487-12354-1235'; + const item = Object.assign(new Item(), { + id: uuid, + uuid: uuid, + metadata: { + 'dspace.entity.type': [{ value: 'Publication' }], + }, + }); + + const tabsRD = createSuccessfulRemoteDataObject(createPaginatedList([tabPublicationsTest, tabDetailsTest, tabFundingsTest])); + const tabsRD$ = createSuccessfulRemoteDataObject$(createPaginatedList([tabPublicationsTest, tabDetailsTest, tabFundingsTest])); + + const noTabsRD = createSuccessfulRemoteDataObject(createPaginatedList([])); + const noTabsRD$ = createSuccessfulRemoteDataObject$(createPaginatedList([])); + + beforeEach(() => { + itemService = jasmine.createSpyObj('ItemDataService', ['findById']); + tabService = jasmine.createSpyObj('TabDataService', ['findByItem']); + hardRedirectService = jasmine.createSpyObj('HardRedirectService', ['redirect']); + + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([{ path: 'entities/:entity-type/:id/:tab', component: {} as any }])], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: TabDataService, useValue: tabService }, + { provide: HardRedirectService, useValue: hardRedirectService }, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + router = TestBed.inject(Router); + }); + + describe('when item exists', () => { + beforeEach(() => { + itemService.findById.and.returnValue(createSuccessfulRemoteDataObject$(item)); + }); + + describe('and there are tabs', () => { + beforeEach(() => { + tabService.findByItem.and.returnValue(tabsRD$); + spyOn(router, 'navigateByUrl'); + }); + + it('should redirect to root route if given tab is the first one', (done) => { + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235/publications' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/entities/publication/1234-65487-12354-1235'); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(tabsRD); + done(); + }); + }); + + it('should not redirect to root route if tab different than the main one is given', (done) => { + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235/details' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(tabsRD); + done(); + }); + }); + + it('should not redirect to root route if no tab is given', (done) => { + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(tabsRD); + done(); + }); + }); + + it('should navigate to 404 if a wrong tab is given', (done) => { + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235/test' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).toHaveBeenCalled(); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(tabsRD); + done(); + }); + }); + + it('Should handle tab shortnames with "::" correctly', (done) => { + const tabPageList = createPaginatedList([{ + ...tabPublicationsTest, + shortname: 'publication::details', + }]); + const tabRD = createSuccessfulRemoteDataObject(tabPageList); + tabService.findByItem.and.returnValue(createSuccessfulRemoteDataObject$(tabPageList) as any); + + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235/details' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(tabRD); + done(); + }); + }); + + it('should NOT redirect to 404 when query params are present in the URL', (done) => { + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235?f.subject=value&f.date=2024' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(tabsRD); + done(); + }); + }); + + it('should NOT redirect to 404 when query params are present with a valid tab', (done) => { + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235/details?f.subject=value' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(tabsRD); + done(); + }); + }); + }); + + describe('and there are no tabs', () => { + beforeEach(() => { + tabService.findByItem.and.returnValue(noTabsRD$); + spyOn(router, 'navigateByUrl'); + }); + + it('should not redirect nor navigate', (done) => { + const obs = TestBed.runInInjectionContext(() => { + return dynamicItemPageTabResolver({ params: { id: uuid } } as any, { url: '/entities/publication/1234-65487-12354-1235' } as any); + }) as Observable>>; + + obs.pipe(take(1)).subscribe((resolved) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(resolved).toEqual(noTabsRD); + done(); + }); + }); + }); + }); + }); +}); diff --git a/src/app/item-page/dynamic-item-page-tab.resolver.ts b/src/app/item-page/dynamic-item-page-tab.resolver.ts new file mode 100644 index 00000000000..fb78bdafbb9 --- /dev/null +++ b/src/app/item-page/dynamic-item-page-tab.resolver.ts @@ -0,0 +1,102 @@ +import { isPlatformServer } from '@angular/common'; +import { + inject, + PLATFORM_ID, +} from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { getPageNotFoundRoute } from '@dspace/core/router/core-routing-paths'; +import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; +import { createFailedRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { Observable } from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { ItemDataService } from '../core/data/item-data.service'; +import { PaginatedList } from '../core/data/paginated-list.model'; +import { RemoteData } from '../core/data/remote-data'; +import { DynamicLayoutTab } from '../core/layout/models/tab.model'; +import { TabDataService } from '../core/layout/tab-data.service'; +import { HardRedirectService } from '../core/services/hard-redirect.service'; +import { Item } from '../core/shared/item.model'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; + +/** + * Route resolver that fetches layout tabs for an item based on the ':id' route parameter. + * + * Resolves the item, then fetches its available tabs from the TabDataService (excluding minor-only tabs). + * Performs tab validation and redirects: + * - If the URL contains an invalid tab shortname, redirects to 404. + * - If the URL contains the main tab's shortname explicitly, redirects to the root item page + * (canonical URL without tab suffix). + * + * @param route The current ActivatedRouteSnapshot containing the item ID + * @param state The current RouterStateSnapshot used for URL analysis + * @returns Observable emitting the RemoteData of paginated DynamicLayoutTab list + */ +export const dynamicItemPageTabResolver: ResolveFn>> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +): Observable>> => { + const platformId = inject(PLATFORM_ID); + const hardRedirectService = inject(HardRedirectService); + const tabService = inject(TabDataService); + const itemDataService = inject(ItemDataService); + const router = inject(Router); + + return itemDataService.findById(route.params.id).pipe( + getFirstCompletedRemoteData(), + switchMap((itemRD: RemoteData) => { + if (itemRD.hasSucceeded && itemRD.statusCode === 200) { + return tabService.findByItem( + itemRD.payload.uuid, + true, + true, + ).pipe( + getFirstCompletedRemoteData(), + map((tabsRD: RemoteData>) => { + if (tabsRD.hasSucceeded && tabsRD?.payload?.page?.length > 0) { + // By splitting the url with uuid we can understand if the item is primary item page or a tab + const urlWithoutQuery = state.url.split('?')[0]; + const urlSplit = urlWithoutQuery.split(route.params.id); + const tabArguments = urlSplit[1]?.split('/'); + const givenTab = tabArguments?.[1]; + const itemPageRoute = getItemPageRoute(itemRD.payload); + + const isValidTab = !givenTab || tabsRD.payload.page.some((tab) => { + const shortnameSplit = tab.shortname.split('::'); + const shortname = shortnameSplit[shortnameSplit.length - 1]; + return shortname === givenTab; + }); + + const mainTab = tabsRD.payload.page.length === 1 + ? tabsRD.payload.page[0] + : tabsRD.payload.page.find(tab => !tab.leading); + + if (!isValidTab) { + // If wrong tab is given redirect to 404 page + router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true, replaceUrl: false }); + } else if (givenTab === mainTab.shortname) { + if (isPlatformServer(platformId)) { + // If first tab is given redirect to root item page + hardRedirectService.redirect(itemPageRoute, 302); + } else { + router.navigateByUrl(itemPageRoute); + } + } + } + return tabsRD; + }), + ); + } else { + return createFailedRemoteDataObject$>(itemRD?.errorMessage, itemRD?.statusCode, itemRD?.timeCompleted); + } + }), + ); +}; diff --git a/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts index f92e94fd0b8..5ab88708972 100644 --- a/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts @@ -24,7 +24,7 @@ import { followLink } from '@dspace/core/shared/follow-link-config.model'; import { Item } from '@dspace/core/shared/item.model'; import { Relationship } from '@dspace/core/shared/item-relationships/relationship.model'; import { RelationshipType } from '@dspace/core/shared/item-relationships/relationship-type.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { hasValue, isNotEmpty, diff --git a/src/app/item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts b/src/app/item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts index 646ce9af34d..45fdd372156 100644 --- a/src/app/item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts +++ b/src/app/item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts @@ -18,7 +18,7 @@ import { } from '@dspace/config/app-config.interface'; import { ObjectUpdatesService } from '@dspace/core/data/object-updates/object-updates.service'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { hasValue } from '@dspace/shared/utils/empty.util'; import { TranslateModule } from '@ngx-translate/core'; import { diff --git a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts index 092cb6ef96d..fb71bbc9cf4 100644 --- a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts +++ b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts @@ -10,7 +10,7 @@ import { } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; import { isNotEmpty } from '@dspace/shared/utils/empty.util'; diff --git a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 145107d602d..dfeb18982a9 100644 --- a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts +++ b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts @@ -3,7 +3,7 @@ import { Component, Input, } from '@angular/core'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { TranslateModule } from '@ngx-translate/core'; import { MetadataFieldWrapperComponent } from '../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts index 7c23933f7a3..27298926968 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts @@ -12,7 +12,7 @@ import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { buildPaginatedList } from '@dspace/core/data/paginated-list.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { PageInfo } from '@dspace/core/shared/page-info.model'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index 29a447ab55e..f972f75580f 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -15,7 +15,7 @@ import { AppConfig, } from '@dspace/config/app-config.interface'; import { BrowseDefinition } from '@dspace/core/shared/browse-definition.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { getFirstCompletedRemoteData, getPaginatedListPayload, diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index d6a6ab20ec1..00f0ba4e0c9 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -10,6 +10,7 @@ import { MenuRoute } from '../shared/menu/menu-route.model'; import { viewTrackerResolver } from '../statistics/angulartics/dspace/view-tracker.resolver'; import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; +import { dynamicItemPageTabResolver } from './dynamic-item-page-tab.resolver'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { itemPageResolver } from './item-page.resolver'; import { @@ -60,6 +61,7 @@ export const ROUTES: Route[] = [ menuRoute: MenuRoute.ITEM_PAGE, }, resolve: { + tabs: dynamicItemPageTabResolver, tracking: viewTrackerResolver, }, }, @@ -107,6 +109,13 @@ export const ROUTES: Route[] = [ menu: accessTokenResolver, }, }, + { + path: ':tab', + component: ThemedItemPageComponent, + resolve: { + tabs: dynamicItemPageTabResolver, + }, + }, ], }, ]; diff --git a/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.html b/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.html index 4a7da94554a..51995aecb2f 100644 --- a/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.html +++ b/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.html @@ -20,7 +20,7 @@ } - @if (showName || !showImage) { + @if ((showName || !showImage) && showUrl) {
{{ showDisclaimer ? ('item.page.cc.license.disclaimer' | translate) : '' }} diff --git a/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.spec.ts index e72e0b06be2..67cc53c52a4 100644 --- a/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.spec.ts @@ -12,10 +12,7 @@ import { APP_CONFIG } from '@dspace/config/app-config.interface'; 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 { - MetadataMap, - MetadataValue, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { MetadataMap } from '@dspace/core/shared/metadata.models'; import { ConfigurationDataServiceStub } from '@dspace/core/testing/configuration-data.service.stub'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; diff --git a/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.ts b/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.ts index 16d096ae389..83818c08ce9 100644 --- a/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component.ts @@ -73,6 +73,20 @@ export class ItemPageCcLicenseFieldComponent implements OnInit { */ @Input() showDisclaimer? = true; + /** + * Shows the title + */ + @Input() showTitle? = true; + + /** + * Shows the label + */ + @Input() showLabel? = true; + + /** + * Shows the url + */ + @Input() showUrl? = true; showImage = true; diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts index f1a7fb95726..7aeea036e29 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts @@ -14,10 +14,7 @@ import { BrowseService } from '@dspace/core/browse/browse.service'; import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definition-data.service'; import { Item } from '@dspace/core/shared/item.model'; import { MathService } from '@dspace/core/shared/math.service'; -import { - MetadataMap, - MetadataValue, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { MetadataMap } from '@dspace/core/shared/metadata.models'; import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '@dspace/core/testing/browse-service.stub'; diff --git a/src/app/item-page/simple/field-components/specific-field/license/item-page-license-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/license/item-page-license-field.component.spec.ts index 7e34aa7255d..0f40173db1e 100644 --- a/src/app/item-page/simple/field-components/specific-field/license/item-page-license-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/license/item-page-license-field.component.spec.ts @@ -12,10 +12,7 @@ import { APP_CONFIG } from '@dspace/config/app-config.interface'; 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 { - MetadataMap, - MetadataValue, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { MetadataMap } from '@dspace/core/shared/metadata.models'; import { ConfigurationDataServiceStub } from '@dspace/core/testing/configuration-data.service.stub'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; diff --git a/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.ts b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.ts index 01ea00274f9..1442acbc34d 100644 --- a/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.ts @@ -16,7 +16,7 @@ import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-d import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model'; import { Item } from '../../../../../core/shared/item.model'; -import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import MetadataValue from '../../../../../core/shared/metadata.models'; import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators'; import { ImageField } from '../image-field'; import { ItemPageFieldComponent } from '../item-page-field.component'; diff --git a/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts b/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts index d526643d9e7..43dff502b7f 100644 --- a/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts @@ -5,7 +5,7 @@ import { } from '@angular/core'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { TranslateModule } from '@ngx-translate/core'; import { MetadataDirective } from '../../../../../shared/metadata.directive'; diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index 4de3dd56b1c..38e1b1176b3 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -1,30 +1,40 @@ -
- @if (itemRD?.hasSucceeded) { -
- @if (itemRD?.payload; as item) { -
- - - - - - - @if (!item.isWithdrawn || (isAdmin$|async)) { - - } - -
+
+
+ @let isDynamicItemPage = (itemRD?.hasSucceeded && !itemRD?.hasNoContent) && tabsRD?.payload?.pageInfo?.totalElements > 0; + @if (isDynamicItemPage) { +
+ +
+ } + @if ((itemRD?.hasSucceeded && !itemRD?.hasNoContent) && tabsRD?.payload?.pageInfo?.totalElements < 1) { +
+ @if (itemRD?.payload; as item) { +
+ + + + + + @if (!item.isWithdrawn || (isAdmin$|async)) { + + } + +
+ } +
+ } + @if (isDynamicItemPage) { + + } + @if (itemRD?.hasFailed) { + @if ((customUrlConflict$ | async); as conflictUrl) { + + } @else { + } -
- } - @if (itemRD?.hasFailed) { - @if ((customUrlConflict$ | async); as conflictUrl) { - - } @else { - } - } - @if (itemRD?.isLoading) { - - } + @if (itemRD?.isLoading) { + + } +
diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index 0e1bf923f7f..6bb1e4191c0 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,6 +1,7 @@ import { AsyncPipe, isPlatformServer, + NgClass, } from '@angular/common'; import { ChangeDetectionStrategy, @@ -18,9 +19,11 @@ import { NotifyInfoService } from '@dspace/core/coar-notify/notify-info/notify-i import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; import { ItemDataService } from '@dspace/core/data/item-data.service'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; import { RemoteData } from '@dspace/core/data/remote-data'; import { SignpostingDataService } from '@dspace/core/data/signposting-data.service'; import { SignpostingLink } from '@dspace/core/data/signposting-links.model'; +import { DynamicLayoutTab } from '@dspace/core/layout/models/tab.model'; import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; import { LinkDefinition, @@ -48,6 +51,7 @@ import { } from 'rxjs/operators'; import { validate as uuidValidate } from 'uuid'; +import { DynamicItemPageComponent } from '../../dynamic-item-page/dynamic-item-page.component'; import { fadeInOut } from '../../shared/animations/fade'; import { ErrorComponent } from '../../shared/error/error.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; @@ -77,10 +81,12 @@ import { QaEventNotificationComponent } from './qa-event-notification/qa-event-n AsyncPipe, CustomUrlConflictErrorComponent, CustomUrlConflictErrorComponent, + DynamicItemPageComponent, ErrorComponent, ItemVersionsComponent, ItemVersionsNoticeComponent, ListableObjectComponentLoaderComponent, + NgClass, NotifyRequestsStatusComponent, QaEventNotificationComponent, ThemedItemAlertsComponent, @@ -141,6 +147,11 @@ export class ItemPageComponent implements OnInit, OnDestroy { coarRestApiUrls: string[] = []; + /** + * The configured tabs for layout of current item + */ + tabsRD$: Observable>>; + protected readonly hasValue = hasValue; constructor( @@ -164,7 +175,9 @@ export class ItemPageComponent implements OnInit, OnDestroy { this.itemRD$ = this.route.data.pipe( map((data) => data.dso as RemoteData), ); - + this.tabsRD$ = this.route.data.pipe( + map((data) => data.tabs as RemoteData>), + ); this.itemPageRoute$ = this.itemRD$.pipe( getAllSucceededRemoteDataPayload(), map((item) => getItemPageRoute(item)), diff --git a/src/app/item-page/simple/item-types/shared/item.component.spec.ts b/src/app/item-page/simple/item-types/shared/item.component.spec.ts index 5052c2cc66b..2b1bddf84cb 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.spec.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.spec.ts @@ -37,7 +37,7 @@ import { HALEndpointService } from '@dspace/core/shared/hal-endpoint.service'; import { Item } from '@dspace/core/shared/item.model'; import { Relationship } from '@dspace/core/shared/item-relationships/relationship.model'; import { RelationshipType } from '@dspace/core/shared/item-relationships/relationship-type.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { PageInfo } from '@dspace/core/shared/page-info.model'; import { UUIDService } from '@dspace/core/shared/uuid.service'; import { WorkspaceitemDataService } from '@dspace/core/submission/workspaceitem-data.service'; diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts index fa22837f3ad..346afb9f313 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts @@ -12,7 +12,7 @@ import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definiti import { RelationshipDataService } from '@dspace/core/data/relationship-data.service'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { MetadatumRepresentation } from '@dspace/core/shared/metadata-representation/metadatum/metadatum-representation.model'; import { BrowseDefinitionDataServiceStub } from '@dspace/core/testing/browse-definition-data-service.stub'; diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts index fc7f1193633..e8b0fc5e5f7 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -8,7 +8,7 @@ import { BrowseDefinitionDataService } from '@dspace/core/browse/browse-definiti import { RelationshipDataService } from '@dspace/core/data/relationship-data.service'; import { MetadataService } from '@dspace/core/metadata/metadata.service'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { MetadataRepresentation } from '@dspace/core/shared/metadata-representation/metadata-representation.model'; import { MetadatumRepresentation } from '@dspace/core/shared/metadata-representation/metadatum/metadatum-representation.model'; import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; diff --git a/src/app/search-page/themed-configuration-search-page.component.ts b/src/app/search-page/themed-configuration-search-page.component.ts index bd60aec2677..e1ec1fc3b42 100644 --- a/src/app/search-page/themed-configuration-search-page.component.ts +++ b/src/app/search-page/themed-configuration-search-page.component.ts @@ -143,6 +143,20 @@ export class ThemedConfigurationSearchPageComponent extends ThemedComponent collapsed', [ + animate('200ms'), + ]), + + ]); diff --git a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts index db9ac721301..2f35265e6bb 100644 --- a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts @@ -19,10 +19,7 @@ import { Bitstream } from '@dspace/core/shared/bitstream.model'; import { Collection } from '@dspace/core/shared/collection.model'; import { Community } from '@dspace/core/shared/community.model'; import { followLink } from '@dspace/core/shared/follow-link-config.model'; -import { - MetadataMap, - MetadataValue, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { MetadataMap } from '@dspace/core/shared/metadata.models'; import { NoContent } from '@dspace/core/shared/NoContent.model'; import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; import { ResourceType } from '@dspace/core/shared/resource-type'; diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts index 54f2e340210..5b0e8c83c56 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts @@ -12,7 +12,7 @@ import { Router, } from '@angular/router'; import { Community } from '@dspace/core/shared/community.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts index a739c7841a0..e1e1f77039f 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts @@ -14,7 +14,7 @@ import { } from '@angular/router'; import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; import { Community } from '@dspace/core/shared/community.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts index f27cbac0529..a7703cdbf2d 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts @@ -12,7 +12,7 @@ import { Router, } from '@angular/router'; import { Collection } from '@dspace/core/shared/collection.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index 47d0b38019e..86704a351a1 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -17,7 +17,7 @@ import { import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { DSpaceObjectType } from '@dspace/core/shared/dspace-object-type.model'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { hasValue } from '@dspace/shared/utils/empty.util'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts index 0b80a0fd420..8f004dc2942 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts @@ -12,7 +12,7 @@ import { Router, } from '@angular/router'; import { Collection } from '@dspace/core/shared/collection.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts index 2b3b44d9cbb..70e28d31d3d 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts @@ -12,7 +12,7 @@ import { Router, } from '@angular/router'; import { Community } from '@dspace/core/shared/community.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts index 4bb40cf65da..f9123488907 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts @@ -12,7 +12,7 @@ import { Router, } from '@angular/router'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/entity-icon/entity-icon.directive.ts b/src/app/shared/entity-icon/entity-icon.directive.ts index 899ddf099d6..2dcc2ce62b8 100644 --- a/src/app/shared/entity-icon/entity-icon.directive.ts +++ b/src/app/shared/entity-icon/entity-icon.directive.ts @@ -62,11 +62,11 @@ export class EntityIconDirective implements OnInit { * 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); + const dynamicRefConfig: AuthorityRefConfig = this.getDynamicRefConfigByType(this.entityType); + if (isNotEmpty(dynamicRefConfig)) { + const entityStyle: AuthorityRefEntityStyleConfig = this.getDynamicRefEntityStyleConfig(dynamicRefConfig, this.entityStyle); + if (isNotEmpty(entityStyle)) { + this.addIcon(entityStyle); } } } @@ -77,7 +77,7 @@ export class EntityIconDirective implements OnInit { * @param type * @private */ - private getCrisRefConfigByType(type: string): AuthorityRefConfig { + private getDynamicRefConfigByType(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'); @@ -89,24 +89,24 @@ export class EntityIconDirective implements OnInit { /** * Return the AuthorityRefEntityStyleConfig by the given style * - * @param crisConfig + * @param dynamicConfig * @param styles * @private */ - private getCrisRefEntityStyleConfig(crisConfig: AuthorityRefConfig, styles: string|string[]): AuthorityRefEntityStyleConfig { + private getDynamicRefEntityStyleConfig(dynamicConfig: 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]; + if (Object.keys(dynamicConfig.entityStyle).includes(style)) { + filteredConf = dynamicConfig.entityStyle[style]; } }); } else { - filteredConf = crisConfig.entityStyle[styles]; + filteredConf = dynamicConfig.entityStyle[styles]; } if (isEmpty(filteredConf) && this.fallbackOnDefault) { - filteredConf = crisConfig.entityStyle.default; + filteredConf = dynamicConfig.entityStyle.default; } return filteredConf; @@ -115,7 +115,7 @@ export class EntityIconDirective implements OnInit { /** * Attach icon to HTML element * - * @param crisStyle + * @param entityStyle * @private */ private addIcon(entityStyle: AuthorityRefEntityStyleConfig): void { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 3bdd5d67f44..26bf93ab853 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -50,10 +50,7 @@ import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/fo import { Item } from '@dspace/core/shared/item.model'; import { Relationship } from '@dspace/core/shared/item-relationships/relationship.model'; import { ReorderableRelationship } from '@dspace/core/shared/item-relationships/reorderable-relationship'; -import { - MetadataValue, - VIRTUAL_METADATA_PREFIX, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { VIRTUAL_METADATA_PREFIX } from '@dspace/core/shared/metadata.models'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { getAllSucceededRemoteData, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts index a08a54ca49e..6160b898deb 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { Item } from '@dspace/core/shared/item.model'; import { ReorderableRelationship } from '@dspace/core/shared/item-relationships/reorderable-relationship'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemMetadataRepresentation } from '@dspace/core/shared/metadata-representation/item/item-metadata-representation.model'; import { MetadataRepresentation } from '@dspace/core/shared/metadata-representation/metadata-representation.model'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index 29a9ea9d776..28041d499e9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -1,4 +1,4 @@ -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { isEmpty, isNotEmpty, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts index d964597e9e4..c3a8d175694 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts @@ -1,5 +1,5 @@ import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RelationshipOptions } from '@dspace/core/shared/relationship-options.model'; import { hasNoValue, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts index d1ac767473d..ae8937ac323 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts @@ -13,7 +13,7 @@ import { Context } from '@dspace/core/shared/context.model'; import { ExternalSourceEntry } from '@dspace/core/shared/external-source-entry.model'; import { Item } from '@dspace/core/shared/item.model'; import { ItemType } from '@dspace/core/shared/item-relationships/item-type.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Metadata } from '@dspace/core/shared/metadata.utils'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ListableObject } from '@dspace/core/shared/object-collection/listable-object.model'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts index 49dd524c9ca..3cb987a2058 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts @@ -12,7 +12,7 @@ import { Item } from '@dspace/core/shared/item.model'; import { ItemType } from '@dspace/core/shared/item-relationships/item-type.model'; import { Relationship } from '@dspace/core/shared/item-relationships/relationship.model'; import { RelationshipType } from '@dspace/core/shared/item-relationships/relationship-type.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { DEBOUNCE_TIME_OPERATOR } from '@dspace/core/shared/operators'; import { WorkspaceItem } from '@dspace/core/submission/models/workspaceitem.model'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 5c31eafff77..d4dc84df83c 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -4,7 +4,7 @@ import { } from '@angular/core'; import { FormFieldModel } from '@dspace/core/shared/form/models/form-field.model'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Metadata } from '@dspace/core/shared/metadata.utils'; import { RelationshipOptions } from '@dspace/core/shared/relationship-options.model'; import { MetadataSecurityConfiguration } from '@dspace/core/submission/models/metadata-security-configuration'; diff --git a/src/app/shared/form/chips/chips.component.html b/src/app/shared/form/chips/chips.component.html index fa9a655d238..1b2ec2af18d 100644 --- a/src/app/shared/form/chips/chips.component.html +++ b/src/app/shared/form/chips/chips.component.html @@ -18,7 +18,7 @@ cdkDrag (cdkDragStarted)="onDrag(i)" href="javascript:void(0);" - [ngClass]="{'chip-selected disabled': (editable && c.editMode) || dragged === i}" + [ngClass]="{'chip-selected disabled': (editable && c.editMode) || dragged === i || !clickable}" (click)="chipsSelected($event, i);"> @if (showIcons && !c.isNestedItem()) { diff --git a/src/app/shared/form/chips/chips.component.ts b/src/app/shared/form/chips/chips.component.ts index 83536a093ae..6df9b04ca42 100644 --- a/src/app/shared/form/chips/chips.component.ts +++ b/src/app/shared/form/chips/chips.component.ts @@ -49,6 +49,7 @@ export class ChipsComponent implements OnChanges { @Input() wrapperClass: string; @Input() editable = true; @Input() showIcons = false; + @Input() clickable = true; @Output() selected: EventEmitter = new EventEmitter(); @Output() remove: EventEmitter = new EventEmitter(); diff --git a/src/app/shared/form/directives/authority-confidence-state.directive.ts b/src/app/shared/form/directives/authority-confidence-state.directive.ts index 2d9fefed4eb..437db5c31df 100644 --- a/src/app/shared/form/directives/authority-confidence-state.directive.ts +++ b/src/app/shared/form/directives/authority-confidence-state.directive.ts @@ -22,7 +22,7 @@ import { import { ConfidenceIconConfig } from '@dspace/config/submission-config.interface'; import { ConfidenceType } from '@dspace/core/shared/confidence-type'; import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyEntryDetail } from '@dspace/core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { 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 index cb89a89f67f..a437d08c1cf 100644 --- 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 @@ -11,7 +11,7 @@ import { TranslateModule, } from '@ngx-translate/core'; import { Item } from 'src/app/core/shared/item.model'; -import { MetadataValue } from 'src/app/core/shared/metadata.models'; +import MetadataValue from 'src/app/core/shared/metadata.models'; import { MetadataLinkViewOrcidComponent } from './metadata-link-view-orcid.component'; 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 index f9c9e4997a5..62252009e52 100644 --- 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 @@ -11,7 +11,7 @@ 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 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'; 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 index 77b3c5e5faf..6f3e499c3cb 100644 --- a/src/app/shared/metadata-link-view/metadata-link-view.component.ts +++ b/src/app/shared/metadata-link-view/metadata-link-view.component.ts @@ -9,9 +9,13 @@ import { } from '@angular/core'; import { RouterLink } from '@angular/router'; import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; 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 { + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; import { NgbPopoverModule, NgbTooltipModule, @@ -27,10 +31,11 @@ import { take, } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; 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 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'; @@ -82,10 +87,24 @@ export class MetadataLinkViewComponent implements OnInit { */ @Input() metadata: MetadataValue; + /** + * Metadata name that we need to show in the template + */ + @Input() metadataName: string|string[]; + + /** + * Item of the metadata value + */ + @Input() item: DSpaceObject; + /** * Where to place the popover */ @Input() popoverPlacement: string; + /** + * The metadata name from where to take the value of the dynamic style + */ + dynamicRefMetadata = environment.layout.dynamicRefStyleMetadata; /** * Processed metadata to create MetadataOrcid with the information needed to show @@ -166,11 +185,13 @@ export class MetadataLinkViewComponent implements OnInit { if (itemRD.hasSucceeded && itemRD.payload) { this.relatedItem = itemRD.payload; this.relatedDsoRoute = this.getItemPageRoute(this.relatedItem); + const entityStyleValue = this.getDynamicRefMetadata(itemRD.payload?.entityType); return { authority: metadataValue.authority, value: metadataValue.value, orcidAuthenticated: this.getOrcid(itemRD.payload), entityType: (itemRD.payload as Item)?.entityType, + entityStyle: itemRD.payload?.firstMetadataValue(entityStyleValue), }; } else { return { @@ -178,6 +199,7 @@ export class MetadataLinkViewComponent implements OnInit { value: metadataValue.value, orcidAuthenticated: null, entityType: 'PRIVATE', + entityStyle: this.metadataName, }; } } @@ -207,6 +229,20 @@ export class MetadataLinkViewComponent implements OnInit { } } + private getDynamicRefMetadata(entity: string): string { + if (isEmpty(this.dynamicRefMetadata)) { + return 'dspace.entity.style'; + } + let metadata; + if (isNotEmpty(entity)) { + const asLowercase = entity.toLowerCase(); + metadata = this.dynamicRefMetadata[Object.keys(this.dynamicRefMetadata) + .find(k => k.toLowerCase() === asLowercase) + ]; + } + return metadata ?? this.dynamicRefMetadata?.default; + } + 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 index 7b0a4fd6f27..5cecbdce5a9 100644 --- a/src/app/shared/metadata-link-view/metadata-view.model.ts +++ b/src/app/shared/metadata-link-view/metadata-view.model.ts @@ -8,4 +8,5 @@ export interface MetadataView { value: string; orcidAuthenticated: string; entityType: string; + entityStyle: string|string[]; } diff --git a/src/app/shared/metadata.directive.spec.ts b/src/app/shared/metadata.directive.spec.ts index 2c34df05e11..8503444a785 100644 --- a/src/app/shared/metadata.directive.spec.ts +++ b/src/app/shared/metadata.directive.spec.ts @@ -4,7 +4,7 @@ import { TestBed, } from '@angular/core/testing'; -import { MetadataValue } from '../core/shared/metadata.models'; +import MetadataValue from '../core/shared/metadata.models'; import { MetadataDirective } from './metadata.directive'; @Component({ diff --git a/src/app/shared/metadata.directive.ts b/src/app/shared/metadata.directive.ts index 468033a4543..675cff31cd2 100644 --- a/src/app/shared/metadata.directive.ts +++ b/src/app/shared/metadata.directive.ts @@ -6,7 +6,7 @@ import { Renderer2, } from '@angular/core'; -import { MetadataValue } from '../core/shared/metadata.models'; +import MetadataValue from '../core/shared/metadata.models'; import { normalizeLanguageCode } from './utils/normalize-language-code-utils'; /** diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts index 8ed767ac4be..64e86cc7c42 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts @@ -7,7 +7,7 @@ import { Metadata } from '@dspace/core/shared/metadata.utils'; import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; import { TranslateModule } from '@ngx-translate/core'; -import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import MetadataValue from '../../../../../core/shared/metadata.models'; import { MetadataDirective } from '../../../../metadata.directive'; import { MetadataFieldWrapperComponent } from '../../../../metadata-field-wrapper/metadata-field-wrapper.component'; import { allMetadataWithHitHighlights } from '../../../../utils/highlighted-metadata.util'; diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/search-result-detail-element.component.ts index a9dc7ad7134..b7df873ab84 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/search-result-detail-element.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/search-result-detail-element.component.ts @@ -7,7 +7,7 @@ import { Metadata } from '@dspace/core/shared/metadata.utils'; import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; import { hasValue } from '@dspace/shared/utils/empty.util'; -import { MetadataValue } from '../../../core/shared/metadata.models'; +import MetadataValue from '../../../core/shared/metadata.models'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { allMetadataWithHitHighlights, diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts index 86ffab84ed3..9f33994715c 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts @@ -34,7 +34,7 @@ import { of, } from 'rxjs'; -import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import MetadataValue from '../../../../../core/shared/metadata.models'; import { ThemedThumbnailComponent } from '../../../../../thumbnail/themed-thumbnail.component'; import { ThemedBadgesComponent } from '../../../../object-collection/shared/badges/themed-badges.component'; import { TruncatableComponent } from '../../../../truncatable/truncatable.component'; diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts index 8fa14ee9b4a..1d4c89ed5a5 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts @@ -8,7 +8,7 @@ import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; import { Item } from '@dspace/core/shared/item.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { TranslateModule } from '@ngx-translate/core'; diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts index d0267020234..2dbe61f5edc 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts @@ -5,7 +5,7 @@ import { import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Metadata } from '@dspace/core/shared/metadata.utils'; import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; import { hasValue } from '@dspace/shared/utils/empty.util'; 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 22594df9832..76acb7765b2 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,7 +15,7 @@ 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 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'; 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 cda4e44bedb..6577bff383d 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 @@ -14,7 +14,7 @@ import { SearchResult } from '@dspace/core/shared/search/models/search-result.mo import { hasValue } from '@dspace/shared/utils/empty.util'; import { Observable } from 'rxjs'; -import { MetadataValue } from '../../../core/shared/metadata.models'; +import MetadataValue from '../../../core/shared/metadata.models'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { TruncatableService } from '../../truncatable/truncatable.service'; import { diff --git a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts index c295426ebfb..62b04c1f004 100644 --- a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts +++ b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts @@ -4,7 +4,7 @@ import { TestBed, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; diff --git a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts index ab3f3403517..f4074490f1c 100644 --- a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts +++ b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts @@ -4,7 +4,7 @@ import { Input, OnInit, } from '@angular/core'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts index a2c9dd61997..0735758ba7b 100644 --- a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts @@ -12,7 +12,7 @@ import { Router, } from '@angular/router'; import { Community } from '@dspace/core/shared/community.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createSuccessfulRemoteDataObject } from '@dspace/core/utilities/remote-data.utils'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; diff --git a/src/app/shared/search/search-results/search-results.component.html b/src/app/shared/search/search-results/search-results.component.html index e1039b8d31b..dedb424186c 100644 --- a/src/app/shared/search/search-results/search-results.component.html +++ b/src/app/shared/search/search-results/search-results.component.html @@ -20,6 +20,9 @@

{{ (configuration ? configuration + '.search.results.head' : 'search.results

@if (searchResults && searchResults?.hasSucceeded && !searchResults?.isLoading && searchResults?.payload?.page.length > 0) {
+ @if (showSearchResultNotice) { + + } >>; @Input() searchConfig: PaginatedSearchOptions; @@ -42,6 +47,8 @@ export class ThemedSearchResultsComponent extends ThemedComponent self-closing tags are NOT whitelisted here and will be escaped by default. + */ + private readonly allowedTags: string[] = ['em', 'strong']; + + constructor(private sanitizer: DomSanitizer) {} + + /** + * Escape HTML special characters and convert newlines to
+ * Then selectively unescape allowed tags only if they have both opening and closing tags. + * + * Examples: + * - "Lorem" retains the tag + * - "Lorem" escapes both tags + * - "Lorem ipsum" escapes the unclosed while retaining the valid + */ + transform(text: string): SafeHtml { + if (text == null) { + return ''; + } + + let processed = text; + + // Step 1: Protect well-formed whitelisted tag pairs by replacing them with placeholders + for (const tag of this.allowedTags) { + const openRaw = `<${tag}>`; + const closeRaw = ``; + const openEsc = `<${tag}>`; + const closeEsc = `</${tag}>`; + + const placeholderOpen = `__DS_ALLOWED_OPEN_${tag.toUpperCase()}__`; + const placeholderClose = `__DS_ALLOWED_CLOSE_${tag.toUpperCase()}__`; + + // Raw HTML pair: ... + const rawPattern = new RegExp(`${openRaw}(.*?)${closeRaw}`, 'gis'); + processed = processed.replace(rawPattern, `${placeholderOpen}$1${placeholderClose}`); + + // Escaped HTML pair: <tag>... </tag> + const escPattern = new RegExp(`${openEsc}(.*?)${closeEsc}`, 'gis'); + processed = processed.replace(escPattern, `${placeholderOpen}$1${placeholderClose}`); + } + + // Step 2: Escape all remaining < and > characters + processed = processed.replace(/>/g, '>').replace(/`).replace(placeholderClose, ``); + } + + // Step 4: Convert newlines to
+ processed = processed.replace(/\n/g, '
'); + + return this.sanitizer.bypassSecurityTrustHtml(processed); + } + +} diff --git a/src/app/shared/utils/highlighted-metadata.util.ts b/src/app/shared/utils/highlighted-metadata.util.ts index e6177305c94..c77269fb18d 100644 --- a/src/app/shared/utils/highlighted-metadata.util.ts +++ b/src/app/shared/utils/highlighted-metadata.util.ts @@ -1,7 +1,4 @@ -import { - MetadataMapInterface, - MetadataValue, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { MetadataMapInterface } from '@dspace/core/shared/metadata.models'; import { Metadata } from '@dspace/core/shared/metadata.utils'; /** diff --git a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts index 3fedb8ac9d4..1a2655ec446 100644 --- a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts +++ b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.ts @@ -7,7 +7,7 @@ import { import { Router } from '@angular/router'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { ExternalSourceEntry } from '@dspace/core/shared/external-source-entry.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { Metadata } from '@dspace/core/shared/metadata.utils'; import { SubmissionObject } from '@dspace/core/submission/models/submission-object.model'; import { diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts index d80bdd9b0b1..086a9aee906 100644 --- a/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts +++ b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts @@ -27,7 +27,7 @@ import { Collection } from '@dspace/core/shared/collection.model'; import { Duplicate } from '@dspace/core/shared/duplicate-data/duplicate.model'; import { DUPLICATE } from '@dspace/core/shared/duplicate-data/duplicate.resource-type'; import { License } from '@dspace/core/shared/license.model'; -import { MetadataValue } from '@dspace/core/shared/metadata.models'; +import MetadataValue from '@dspace/core/shared/metadata.models'; import { SectionsType } from '@dspace/core/submission/sections-type'; import { SubmissionScopeType } from '@dspace/core/submission/submission-scope-type'; import { NotificationsServiceStub } from '@dspace/core/testing/notifications-service.stub'; diff --git a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts index 1baf2740d92..c2ce29044cf 100644 --- a/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts +++ b/src/app/submission/sections/upload/file/view/section-upload-file-view.component.ts @@ -4,10 +4,7 @@ import { Input, OnInit, } from '@angular/core'; -import { - MetadataMap, - MetadataValue, -} from '@dspace/core/shared/metadata.models'; +import MetadataValue, { MetadataMap } from '@dspace/core/shared/metadata.models'; import { Metadata } from '@dspace/core/shared/metadata.utils'; import { WorkspaceitemSectionUploadFileObject } from '@dspace/core/submission/models/workspaceitem-section-upload-file.model'; import { isNotEmpty } from '@dspace/shared/utils/empty.util'; diff --git a/src/app/utils/date.util.ts b/src/app/utils/date.util.ts index 40264c23fc8..113c8b62f12 100644 --- a/src/app/utils/date.util.ts +++ b/src/app/utils/date.util.ts @@ -133,3 +133,20 @@ export function dateValueToString(value: string | Date | object): string { `Unsupported date value type: expected a string, Date, or object with {year, month, day} properties, but received: ${JSON.stringify(value)}`, ); } + +export function localeDate(date: string, locale?: string): string { + const parts = date.split('-').map(part => parseInt(part, 10)); + const year = parts[0]; + const month = parts.length > 1 ? parts[1] - 1 : 0; // Default to January if no month + const day = parts.length > 2 ? parts[2] : 1; // Default to the first day if no day + + const dateObj = new Date(year, month, day); + + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: parts.length > 1 ? 'long' : undefined, // Show month only if provided + day: parts.length > 2 ? 'numeric' : undefined, // Show day only if provided + }; + + return dateObj.toLocaleDateString(locale, options); +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 9613e02b72a..9039102318f 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1791,6 +1791,48 @@ "curation-task.task.citationpage.label": "Generate Citation Page", + "dynamic-layout.toggle.open": "Open section", + + "dynamic-layout.toggle.close": "Close section", + + "dynamic-layout.toggle.aria.open": "Expand {{sectionHeader}} section", + + "dynamic-layout.toggle.aria.close": "Collapse {{sectionHeader}} section", + + "dynamic-layout.advanced-attachment.dc.title": "Name", + + "dynamic-layout.advanced-attachment.size": "Size", + + "dynamic-layout.advanced-attachment.checksum": "Checksum", + + "dynamic-layout.advanced-attachment.checksum.info.MD5": "The MD5 message-digest algorithm can be used to verify the integrity of the file that you have uploaded. You can calculate its value locally via tools that are generally available in each operative system like md5sum", + + "dynamic-layout.advanced-attachment.format": "Format", + + "dynamic-layout.advanced-attachment.dc.type": "Type", + + "dynamic-layout.advanced-attachment.dc.description": "Description", + + "dynamic-layout.advanced-attachment.no_thumbnail": "No Thumbnail Available", + + "dynamic-layout.advanced-attachment.download": "Download", + + "dynamic-layout.advanced-attachment.requestACopy": "Request a copy", + + "dynamic-layout.advanced-attachment.viewMore": "View More", + + "dynamic-layout.advanced-attachment.label.not-present": "(not present)", + + "dynamic-layout.attachment.viewMore": "View More", + + "dynamic-layout.rendering.collections.owning-collection.label": "Owning collection", + + "dynamic-layout.rendering.collections.mapped-collection.label": "Mapped collections", + + "dynamic-layout.rendering.collections.loading": "Loading...", + + "dynamic-layout.rendering.collections.load-more": "Load more", + "curation-task.task.checklinks.label": "Check Links in Metadata", "curation-task.task.noop.label": "NOOP", @@ -7887,4 +7929,161 @@ "bitstream.related.isReplacedBy": "Is replaced by", "bitstream.related.deleted": "deleted", + + // Layout - Autogenerated i18n keys: Tab headers + + "layout.tab.header.Person.details": "Profile", + + "layout.tab.header.Person.bitstreams": "Personal File(s)", + + "layout.tab.header.Person.secure": "Secured Tab", + + "layout.tab.header.OrgUnit.details": "Details", + + "layout.tab.header.OrgUnit.bitstreams": "Files", + + "layout.tab.header.Project.heading": "Heading", + + "layout.tab.header.Project.details": "Details", + + "layout.tab.header.Project.bitstreams": "Files", + + "layout.tab.header.Journal.details": "Details", + + "layout.tab.header.Publication.details": "Details", + + // Layout - Autogenerated i18n keys: box headers + + "layout.box.header.Journal.details": "Journal description", + + "layout.box.header.OrgUnit.details": "Details", + + "layout.box.header.OrgUnit.bitstreams": "Files", + + "layout.box.header.Person.secure": "Secured box", + + "layout.box.header.Person.bitstreams": "Personal File(s)", + + "layout.box.header.Project.primarydata": "Primary Data", + + "layout.box.header.Project.bitstreams": "Files", + + // Layout - Autogenerated i18n keys: metadata labels + + "layout.field.label.Journal.creativework.editor": "Editor(s)", + + "layout.field.label.Journal.creativeworkseries.issn": "ISSN", + + "layout.field.label.Journal.dc.description": "Description", + + "layout.field.label.Journal.creativework.publisher": "Publisher", + + "layout.field.label.Journal.BITSTREAM": "File(s)", + + "layout.field.label.OrgUnit.dc.identifier": "ID", + + "layout.field.label.OrgUnit.organization.foundingDate": "Date established", + + "layout.field.label.OrgUnit.organization.address.addressLocality": "City", + + "layout.field.label.OrgUnit.organization.address.addressCountry": "Country", + + "layout.field.label.OrgUnit.dc.description": "Description", + + "layout.field.label.OrgUnit.BITSTREAM": "File(s)", + + "layout.field.label.Person.person.familyName": "Last name", + + "layout.field.label.Person.person.givenName": "First name", + + "layout.field.label.Person.person.email": "Email", + + "layout.field.label.Person.person.birthDate": "Birth Date", + + "layout.field.label.Project.dc.identifier": "ID", + + "layout.field.label.Project.project.investigator": "Investigator", + + "layout.field.label.Project.dc.subject": "Keywords", + + "layout.field.label.Project.project.startDate": "Start date", + + "layout.field.label.Project.project.endDate": "End Date", + + "layout.field.label.Project.project.amount": "Amount", + + "layout.field.label.Project.project.amount.currency": "Currency", + + "layout.field.label.Project.dc.description": "Description", + + "layout.field.label.Project.BITSTREAM": "File(s)", + + "layout.field.label.Publication.dc.title.alternative": "Other Titles", + + "layout.field.label.Publication.dc.contributor.author": "Author(s)", + + "layout.field.label.Publication.dc.contributor.editor": "Editor(s)", + + "layout.field.label.Publication.dc.date.issued": "Date Issued", + + "layout.field.label.Publication.dc.type": "Type", + + "layout.field.label.Publication.dc.publisher": "Publisher", + + "layout.field.label.Publication.dc.relation.journal": "Journal", + + "layout.field.label.Publication.dc.relation.ispartofseries ": "Series/Report No.", + + "layout.field.label.Publication.dc.relation.publication": "Contained in", + + "layout.field.label.Publication.dc.identifier.doi": "DOI", + + "layout.field.label.Publication.dc.relation.issn": "ISSN", + + "layout.field.label.Publication.dc.relation.isbn": "ISBN", + + "layout.field.label.Publication.dc.description.abstract": "Abstract", + + "layout.field.label.Publication.dc.description": "Additional information", + + "layout.field.label.Publication.dc.identifier.citation": "Citation", + + "layout.field.label.Publication.dc.subject": "Keywords", + + "layout.field.label.Publication.dc.description.sponsorship": "Sponsor(s)", + + "layout.field.label.Publication.BITSTREAM": "File(s)", + + // Layout - Autogenerated i18n keys: metadata groups labels + + "layout.field.label.Person.oairecerif.affiliation.role": "Role", + + "layout.field.label.Person.oairecerif.person.affiliation": "Organisation", + + "layout.field.label.Person.oairecerif.affiliation.startDate": "Start", + + "layout.field.label.Person.oairecerif.affiliation.endDate": "End", + + "layout.field.label.Person.crisrp.education.role": "Role", + + "layout.field.label.Person.crisrp.education": "Organisation", + + "layout.field.label.Person.crisrp.education.start": "Start", + + "layout.field.label.Person.crisrp.education.end": "End", + + "layout.field.label.Person.crisrp.qualification": "Title", + + "layout.field.label.Person.crisrp.qualification.start": "Start", + + "layout.field.label.Person.crisrp.qualification.end": "End", + + "layout.field.label.Publication.dc.contributor.author": "Author", + + "layout.field.label.Publication.oairecerif.author.affiliation": "Affiliation", + + "layout.field.label.Publication.dc.contributor.editor": "Editor", + + "layout.field.label.Publication.oairecerif.editor.affiliation": "Affiliation", + } diff --git a/src/config/advanced-attachment-rendering.config.ts b/src/config/advanced-attachment-rendering.config.ts index cbbf4a6f9d0..ee6d4d6e4f5 100644 --- a/src/config/advanced-attachment-rendering.config.ts +++ b/src/config/advanced-attachment-rendering.config.ts @@ -1,24 +1,44 @@ /** - * Interface configuration to define the advanced attachment rendering settings + * Configuration for advanced attachment rendering in the dynamic layout system. + * Controls which metadata/attributes are displayed for bitstream attachments + * and how pagination is handled. */ export interface AdvancedAttachmentRenderingConfig { + /** Array of metadata/attribute columns to display for each attachment. */ metadata: AttachmentMetadataConfig[]; + /** Pagination configuration for the attachment list. */ + pagination: AttachmentRenderingPaginationConfig } /** - * Interface configuration to select which are the advanced attachment information to show + * Configuration for a single metadata or attribute column in the advanced attachment display. */ export interface AttachmentMetadataConfig { + /** The metadata field name or attribute identifier. */ name: string; + /** Whether this config refers to a metadata field or a bitstream attribute. */ type: AdvancedAttachmentElementType; + /** Whether long values should be truncatable with a "show more" toggle. */ truncatable?: boolean; } /** - * Interface configuration to define the type for each element shown in the advanced attachment feature + * Discriminator for advanced attachment element types. */ export enum AdvancedAttachmentElementType { + /** Refers to a bitstream metadata field. */ Metadata = 'metadata', + /** Refers to a bitstream attribute (e.g., file size, format). */ Attribute = 'attribute' } +/** + * Pagination settings for the attachment rendering list. + */ +export interface AttachmentRenderingPaginationConfig { + /** Whether pagination is enabled for the attachment list. */ + enabled: boolean; + /** Maximum number of attachments to display per page. */ + elementsPerPage: number; +} + diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index bd2667e9efe..2f0ad3e5a41 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -3,6 +3,8 @@ import { makeStateKey, } from '@angular/core'; +import { FieldRenderingType } from '../app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/field-rendering-type'; +import { MetadataBoxFieldRenderOptions } from '../app/dynamic-layout/dynamic-layout-matrix/dynamic-layout-box-container/boxes/metadata/rendering-types/rendering-type.model'; import { AccessibilitySettingsConfig } from './accessibility-settings.config'; import { ActuatorsConfig } from './actuators.config'; import { AddToAnyPluginConfig } from './add-to-any-plugin-config'; @@ -87,6 +89,8 @@ const APP_CONFIG = new InjectionToken('APP_CONFIG'); const APP_CONFIG_STATE = makeStateKey('APP_CONFIG_STATE'); +export const DYNAMIC_FIELD_RENDERING_MAP: InjectionToken> = new InjectionToken>('DYNAMIC_FIELD_RENDERING_MAP'); + type DeepPartial = T extends object ? { [k in keyof T]?: DeepPartial} : T; /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index fd703d4d024..28dd1467c6d 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -814,6 +814,55 @@ export class DefaultAppConfig implements AppConfig { type: AdvancedAttachmentElementType.Attribute, }, ], + pagination: { + enabled: true, + elementsPerPage: 2, + }, + }, + urn: [ + { + name: 'doi', + baseUrl: 'https://doi.org/', + }, + { + name: 'hdl', + baseUrl: 'https://hdl.handle.net/', + }, + { + name: 'scopus', + baseUrl: 'https://www.scopus.com/authid/detail.uri?authorId=', + }, + { + name: 'researcherid', + baseUrl: 'http://www.researcherid.com/rid/', + }, + { + name: 'mailto', + baseUrl: 'mailto:', + }, + ], + itemPage: { + OrgUnit: { + orientation: 'vertical', + }, + Project: { + orientation: 'vertical', + }, + default: { + orientation: 'horizontal', + }, + }, + metadataBox: { + defaultMetadataLabelColStyle: 'col-3', + defaultMetadataValueColStyle: 'col-9', + }, + collectionsBox: { + defaultCollectionsLabelColStyle: 'col-3 fw-bold', + defaultCollectionsValueColStyle: 'col-9', + isInline: true, + }, + dynamicRefStyleMetadata: { + default: 'dspace.entity.style', }, }; diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index 709292a0068..3a56ac115fb 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -53,11 +53,79 @@ export interface AuthorityRefConfig extends Config { }; } +/** + * Configuration for a URN (Uniform Resource Name) resolver entry. + * Maps an identifier scheme name (e.g., 'doi', 'handle', 'scopus') to a base URL + * used by {@link ResolverStrategyService} to construct external links. + */ +export interface UrnConfig extends Config { + /** The URN scheme name (e.g., 'doi', 'handle', 'scopus', 'orcid'). */ + name: string; + /** The base URL to prepend when resolving this URN type. */ + baseUrl: string; + /** Whether to preserve whitespace characters in the identifier value. */ + shouldKeepWhiteSpaces?: boolean; +} + +/** + * Configuration specifying the layout orientation for a dynamic item page. + */ +export interface DynamicLayoutTypeConfig { + /** The page orientation: 'horizontal' (tabs on top) or 'vertical' (tabs in sidebar). */ + orientation: string; +} + +/** + * Configuration for default CSS column styles in metadata box rendering. + */ +export interface DynamicLayoutMetadataBoxConfig extends Config { + /** Default Bootstrap column class for metadata labels (e.g., 'col-4'). */ + defaultMetadataLabelColStyle: string; + /** Default Bootstrap column class for metadata values (e.g., 'col-8'). */ + defaultMetadataValueColStyle: string; +} + +/** + * Configuration for default CSS column styles in collections box rendering. + */ +export interface DynamicLayoutCollectionsBoxConfig extends Config { + /** Default Bootstrap column class for collection labels. */ + defaultCollectionsLabelColStyle: string; + /** Default Bootstrap column class for collection values. */ + defaultCollectionsValueColStyle: string; + /** Whether to render collections inline rather than stacked. */ + isInline: boolean; + /** Optional CSS classes for the collections row container. */ + defaultCollectionsRowStyle?: string; +} + +/** + * Configuration mapping entity types to their item page layout orientation. + * Must include a 'default' key as fallback for entity types without explicit configuration. + */ +export interface DynamicItemPageConfig extends Config { + /** Layout configuration for a specific entity type (key = entity type label). */ + [entity: string]: DynamicLayoutTypeConfig; + /** Default layout configuration used when no entity-specific config is found. */ + default: DynamicLayoutTypeConfig; +} + +/** + * Configuration mapping metadata field names to CSS class names for dynamic reference styling. + * Must include a 'default' key as fallback. + */ +export interface DynamicRefStyleMetadata extends Config { + /** CSS class for a specific metadata field (key = metadata field name). */ + [metadata: string]: string; + /** Default CSS class used when no specific metadata match is found. */ + default: string; +} + /** * Top-level layout configuration for UI visual customization. * - * Provides configuration for customizing the visual appearance and layout of entities - * Currently supports authority reference styling for displaying icons and styles for different entity types (persons, organizations, projects, etc.). + * Provides configuration for customizing the visual appearance and layout of entities, + * including authority reference styling, attachment rendering, and dynamic item page layouts. * @see AuthorityRefConfig * @see AppConfig */ @@ -67,10 +135,44 @@ export interface LayoutConfig extends Config { * Each entry defines how entities of a specific type should be visually represented with icons and styles. */ authorityRef: AuthorityRefConfig[]; + + /** + * If true the download link in item page will be rendered as an advanced attachment. + */ showDownloadLinkAsAttachment: boolean; + /** * Configuration for advanced attachment rendering features. * Controls pagination and metadata display for bitstream attachments. */ advancedAttachmentRendering: AdvancedAttachmentRenderingConfig; + + /** + * Array of URN (Uniform Resource Name) configurations mapping identifiers to their base URLs. + * Used for resolving identifiers like DOI, Handle, Scopus, etc. to their external URLs. + */ + urn: UrnConfig[]; + + /** + * Item page layout configuration for different entity types. + * Defines the orientation (horizontal/vertical) of item page layouts based on entity type. + */ + itemPage: DynamicItemPageConfig; + + /** + * Metadata box rendering configuration. + * Defines default CSS column styles for metadata labels and values. + */ + metadataBox: DynamicLayoutMetadataBoxConfig; + + /** + * Collections box rendering configuration. + * Defines default CSS column styles for collection labels and values, and inline display settings. + */ + collectionsBox: DynamicLayoutCollectionsBoxConfig; + + /** + * Metadata that holds information about the style + */ + dynamicRefStyleMetadata: DynamicRefStyleMetadata, } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 0ae82e957a1..a15aced430d 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -1,5 +1,6 @@ // This configuration is only used for unit tests, end-to-end tests use environment.production.ts import { AdvancedAttachmentElementType } from '@dspace/config/advanced-attachment-rendering.config'; +import { IdentifierSubtypesIconPositionEnum } from '@dspace/config/identifier-subtypes-config.interface'; import { NotificationAnimationsType } from '@dspace/config/notifications-config.interfaces'; import { RestRequestMethod } from '@dspace/config/rest-request-method'; import { BuildConfig } from 'src/config/build-config.interface'; @@ -341,7 +342,14 @@ export const environment: BuildConfig = { metadataList: ['dc.identifier.doi', 'dc.identifier.uri', 'dc.description.abstract'], }, ], - identifierSubtypes: [], + identifierSubtypes: [ + { + name: 'ror', + icon: 'assets/images/ror.logo.icon.svg', + iconPosition: IdentifierSubtypesIconPositionEnum.LEFT, + link: 'https://ror.org', + }, + ], }, showAuthorityRelations: false, }, @@ -602,6 +610,52 @@ export const environment: BuildConfig = { type: AdvancedAttachmentElementType.Attribute, }, ], + pagination: { + enabled: true, + elementsPerPage: 2, + }, + }, + urn: [ + { + name: 'doi', + baseUrl: 'https://doi.org/', + }, + { + name: 'keepMyWhiteSpaces', + baseUrl: 'https://keepmywhitespaces.com/', + shouldKeepWhiteSpaces: true, + }, + { + name: 'hdl', + baseUrl: 'https://hdl.handle.net/', + }, + { + name: 'mailto', + baseUrl: 'mailto:', + }, + ], + itemPage: { + Person: { + orientation: 'horizontal', + }, + OrgUnit: { + orientation: 'horizontal', + }, + default: { + orientation: 'vertical', + }, + }, + metadataBox: { + defaultMetadataLabelColStyle: 'col-3', + defaultMetadataValueColStyle: 'col-9', + }, + collectionsBox: { + defaultCollectionsLabelColStyle: 'col-3 fw-bold', + defaultCollectionsValueColStyle: 'col-9', + isInline: true, + }, + dynamicRefStyleMetadata: { + default: 'dspace.entity.style', }, }, diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 0a8a6757fb1..645359240ab 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -146,6 +146,41 @@ --ds-gap: 0.25rem; + + --ds-dynamic-layout-sidebar-width: 350px; + --ds-dynamic-layout-sidebar-background-color: #{$gray-200}; + --ds-dynamic-layout-sidebar-link-color: rgba(0, 0, 0, .8); + --ds-dynamic-layout-sidebar-link-color-active: #000000; + --ds-dynamic-layout-sidebar-menu-background: #207698; + --ds-dynamic-layout-sidebar-menu-color: #fff; + --ds-dynamic-layout-sidebar-link-border-bottom: 1px solid rgba(0, 0, 0, 0.1); + --ds-dynamic-layout-sidebar-entry-height: 40px; + --ds-dynamic-layout-sidebar-entry-width: 40px; + --ds-dynamic-layout-sidebar-border-color: #207698; + --ds-dynamic-layout-sidebar-hover-focus-background-color: #207698; + --ds-dynamic-layout-sidebar-hover-focus-border-color: #207698; + --ds-dynamic-layout-sidebar-menu-active-color: #{$cyan}; + --ds-dynamic-layout-sidebar-menu-active-background: #{map-get($theme-colors,'light')}; + --ds-dynamic-layout-sidebar-tab-color-hover : #{map-get($theme-colors,'light')}; + + --ds-dynamic-layout-navbar-background-color: #{$gray-200}; + --ds-dynamic-layout-navbar-border: 1px solid #d9dadb; + --ds-dynamic-layout-navbar-link-color: rgba(0, 0, 0, .8); + --ds-dynamic-layout-navbar-link-color-active: #000000; + --ds-dynamic-layout-navbar-menu-background: #207698; + --ds-dynamic-layout-navbar-menu-color: #fff; + --ds-dynamic-layout-navbar-link-border-bottom: none; + + --ds-dynamic-layout-navbar-sub-level-backround-color: #{$gray-200}; + --ds-dynamic-layout-navbar-tab-color-active: #006666; + --ds-dynamic-layout-navbar-tab-color-hover : #{map-get($theme-colors,'light')}; + + --ds-advanced-attachment-image-max-height: 160px; + --ds-advanced-attachment-image-object-fit: cover; + --ds-advanced-attachment-image-object-position: 0% 0%; + --ds-advanced-attachment-thumbnail-placeholder-font-weight: bold; + + --ds-dso-edit-field-width: 210px; --ds-dso-edit-lang-width: 90px; --ds-dso-edit-security-width: 136px; diff --git a/src/themes/custom/app/item-page/simple/item-page.component.ts b/src/themes/custom/app/item-page/simple/item-page.component.ts index 81c8cd98033..ea6459308cd 100644 --- a/src/themes/custom/app/item-page/simple/item-page.component.ts +++ b/src/themes/custom/app/item-page/simple/item-page.component.ts @@ -1,10 +1,14 @@ -import { AsyncPipe } from '@angular/common'; +import { + AsyncPipe, + NgClass, +} from '@angular/common'; import { ChangeDetectionStrategy, Component, } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; +import { DynamicItemPageComponent } from '../../../../../app/dynamic-item-page/dynamic-item-page.component'; import { ThemedItemAlertsComponent } from '../../../../../app/item-page/alerts/themed-item-alerts.component'; import { AccessByTokenNotificationComponent } from '../../../../../app/item-page/simple/access-by-token-notification/access-by-token-notification.component'; import { CustomUrlConflictErrorComponent } from '../../../../../app/item-page/simple/custom-url-conflict-error/custom-url-conflict-error.component'; @@ -31,10 +35,12 @@ import { VarDirective } from '../../../../../app/shared/utils/var.directive'; AccessByTokenNotificationComponent, AsyncPipe, CustomUrlConflictErrorComponent, + DynamicItemPageComponent, ErrorComponent, ItemVersionsComponent, ItemVersionsNoticeComponent, ListableObjectComponentLoaderComponent, + NgClass, NotifyRequestsStatusComponent, QaEventNotificationComponent, ThemedItemAlertsComponent, diff --git a/src/themes/custom/app/shared/search/search-results/search-results.component.ts b/src/themes/custom/app/shared/search/search-results/search-results.component.ts index 83be5c18df7..04835674ed9 100644 --- a/src/themes/custom/app/shared/search/search-results/search-results.component.ts +++ b/src/themes/custom/app/shared/search/search-results/search-results.component.ts @@ -4,6 +4,7 @@ import { RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { AlertComponent } from '../../../../../../app/shared/alert/alert.component'; import { fadeIn, fadeInOut, @@ -24,6 +25,7 @@ import { SearchResultsSkeletonComponent } from '../../../../../../app/shared/sea fadeInOut, ], imports: [ + AlertComponent, AsyncPipe, ErrorComponent, NgxSkeletonLoaderModule,