diff --git a/angular.json b/angular.json index b151a355acc..c7e3ff7ce71 100644 --- a/angular.json +++ b/angular.json @@ -49,6 +49,7 @@ "styles": [ "src/styles/startup.scss", "src/aai/discojuice/discojuice.css", + "node_modules/font-awesome/css/font-awesome.css", "node_modules/bootstrap/dist/css/bootstrap.min.css", { "input": "src/styles/base-theme.scss", diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 6b67549f2d7..f8bb6ecba0e 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -3,22 +3,39 @@ import { combineLatest as observableCombineLatest, Observable, of as observableOf, - race as observableRace + race as observableRace, } from 'rxjs'; -import { map, switchMap, filter, distinctUntilKeyChanged } from 'rxjs/operators'; -import { hasValue, isEmpty, isNotEmpty, hasNoValue, isUndefined } from '../../../shared/empty.util'; +import { + map, + switchMap, + filter, + distinctUntilKeyChanged, +} from 'rxjs/operators'; +import { + hasValue, + isEmpty, + isNotEmpty, + hasNoValue, + isUndefined, +} from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model'; +import { + FollowLinkConfig, + followLink, +} from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list.model'; import { RemoteData } from '../../data/remote-data'; import { RequestEntry, ResponseState, RequestEntryState, - hasSucceeded + hasSucceeded, } from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; -import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/operators'; +import { + getRequestFromRequestHref, + getRequestFromRequestUUID, +} from '../../shared/operators'; import { ObjectCacheService } from '../object-cache.service'; import { LinkService } from './link.service'; import { HALLink } from '../../shared/hal-link.model'; @@ -31,10 +48,11 @@ import { getResourceTypeValueFor } from '../object-cache.reducer'; @Injectable() export class RemoteDataBuildService { - constructor(protected objectCache: ObjectCacheService, - protected linkService: LinkService, - protected requestService: RequestService) { - } + constructor( + protected objectCache: ObjectCacheService, + protected linkService: LinkService, + protected requestService: RequestService + ) {} /** * Creates an Observable with the payload for a RemoteData object @@ -48,21 +66,36 @@ export class RemoteDataBuildService { * should be automatically resolved * @private */ - private buildPayload(requestEntry$: Observable, href$?: Observable, ...linksToFollow: FollowLinkConfig[]): Observable { + private buildPayload( + requestEntry$: Observable, + href$?: Observable, + ...linksToFollow: FollowLinkConfig[] + ): Observable { if (hasNoValue(href$)) { href$ = observableOf(undefined); } return observableCombineLatest([href$, requestEntry$]).pipe( switchMap(([href, entry]: [string, RequestEntry]) => { - const hasExactMatchInObjectCache = this.hasExactMatchInObjectCache(href, entry); - if (hasValue(entry.response) && - (hasExactMatchInObjectCache || this.isCacheablePayload(entry) || this.isUnCacheablePayload(entry))) { + const hasExactMatchInObjectCache = this.hasExactMatchInObjectCache( + href, + entry + ); + if ( + hasValue(entry.response) && + (hasExactMatchInObjectCache || + this.isCacheablePayload(entry) || + this.isUnCacheablePayload(entry)) + ) { if (hasExactMatchInObjectCache) { return this.objectCache.getObjectByHref(href); } else if (this.isCacheablePayload(entry)) { - return this.objectCache.getObjectByHref(entry.response.payloadLink.href); + return this.objectCache.getObjectByHref( + entry.response.payloadLink.href + ); } else { - return [this.plainObjectToInstance(entry.response.unCacheableObject)]; + return [ + this.plainObjectToInstance(entry.response.unCacheableObject), + ]; } } else if (hasSucceeded(entry.state)) { return [null]; @@ -72,7 +105,9 @@ export class RemoteDataBuildService { }), switchMap((obj: T) => { if (hasValue(obj)) { - if (getResourceTypeValueFor((obj as any).type) === PAGINATED_LIST.value) { + if ( + getResourceTypeValueFor((obj as any).type) === PAGINATED_LIST.value + ) { return this.buildPaginatedList(obj, ...linksToFollow); } else if (isNotEmpty(linksToFollow)) { return [this.linkService.resolveLinks(obj, ...linksToFollow)]; @@ -109,9 +144,17 @@ export class RemoteDataBuildService { * @param entry the request entry the object has to match * @private */ - private hasExactMatchInObjectCache(href: string, entry: RequestEntry): boolean { - return hasValue(entry) && hasValue(entry.request) && isNotEmpty(entry.request.uuid) && - hasValue(href) && this.objectCache.hasByHref(href, entry.request.uuid); + private hasExactMatchInObjectCache( + href: string, + entry: RequestEntry + ): boolean { + return ( + hasValue(entry) && + hasValue(entry.request) && + isNotEmpty(entry.request.uuid) && + hasValue(href) && + this.objectCache.hasByHref(href, entry.request.uuid) + ); } /** @@ -120,7 +163,10 @@ export class RemoteDataBuildService { * @private */ private isCacheablePayload(entry: RequestEntry): boolean { - return hasValue(entry.response.payloadLink) && isNotEmpty(entry.response.payloadLink.href); + return ( + hasValue(entry.response.payloadLink) && + isNotEmpty(entry.response.payloadLink.href) + ); } /** @@ -140,26 +186,40 @@ export class RemoteDataBuildService { * @param object A plain object to be turned in to a {@link PaginatedList} * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - private buildPaginatedList(object: any, ...linksToFollow: FollowLinkConfig[]): Observable { - const pageLink = linksToFollow.find((linkToFollow: FollowLinkConfig) => linkToFollow.name === 'page'); - const otherLinks = linksToFollow.filter((linkToFollow: FollowLinkConfig) => linkToFollow.name !== 'page'); + private buildPaginatedList( + object: any, + ...linksToFollow: FollowLinkConfig[] + ): Observable { + const pageLink = linksToFollow.find( + (linkToFollow: FollowLinkConfig) => linkToFollow.name === 'page' + ); + const otherLinks = linksToFollow.filter( + (linkToFollow: FollowLinkConfig) => linkToFollow.name !== 'page' + ); const paginatedList = Object.assign(new PaginatedList(), object); if (hasValue(pageLink)) { if (isEmpty(paginatedList.page)) { - const pageSelfLinks = paginatedList._links.page.map((link: HALLink) => link.href); - return this.objectCache.getList(pageSelfLinks).pipe(map((page: any[]) => { - paginatedList.page = page - .map((obj: any) => this.plainObjectToInstance(obj)) - .map((obj: any) => - this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) - ); - if (isNotEmpty(otherLinks)) { - return this.linkService.resolveLinks(paginatedList, ...otherLinks); - } - return paginatedList; - })); + const pageSelfLinks = paginatedList._links.page.map( + (link: HALLink) => link.href + ); + return this.objectCache.getList(pageSelfLinks).pipe( + map((page: any[]) => { + paginatedList.page = page + .map((obj: any) => this.plainObjectToInstance(obj)) + .map((obj: any) => + this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) + ); + if (isNotEmpty(otherLinks)) { + return this.linkService.resolveLinks( + paginatedList, + ...otherLinks + ); + } + return paginatedList; + }) + ); } else { // in case the elements of the paginated list were already filled in, because they're UnCacheableObjects paginatedList.page = paginatedList.page @@ -168,7 +228,9 @@ export class RemoteDataBuildService { this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) ); if (isNotEmpty(otherLinks)) { - return observableOf(this.linkService.resolveLinks(paginatedList, ...otherLinks)); + return observableOf( + this.linkService.resolveLinks(paginatedList, ...otherLinks) + ); } } } @@ -181,13 +243,22 @@ export class RemoteDataBuildService { * @param requestUUID$ The UUID of the request we want to retrieve * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - buildFromRequestUUID(requestUUID$: string | Observable, ...linksToFollow: FollowLinkConfig[]): Observable> { + buildFromRequestUUID( + requestUUID$: string | Observable, + ...linksToFollow: FollowLinkConfig[] + ): Observable> { if (typeof requestUUID$ === 'string') { requestUUID$ = observableOf(requestUUID$); } - const requestEntry$ = requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)); + const requestEntry$ = requestUUID$.pipe( + getRequestFromRequestUUID(this.requestService) + ); - const payload$ = this.buildPayload(requestEntry$, undefined, ...linksToFollow); + const payload$ = this.buildPayload( + requestEntry$, + undefined, + ...linksToFollow + ); return this.toRemoteDataObservable(requestEntry$, payload$); } @@ -198,7 +269,10 @@ export class RemoteDataBuildService { * @param href$ self link of object we want to retrieve * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - buildFromHref(href$: string | Observable, ...linksToFollow: FollowLinkConfig[]): Observable> { + buildFromHref( + href$: string | Observable, + ...linksToFollow: FollowLinkConfig[] + ): Observable> { if (typeof href$ === 'string') { href$ = observableOf(href$); } @@ -207,17 +281,20 @@ export class RemoteDataBuildService { const requestUUID$ = href$.pipe( switchMap((href: string) => - this.objectCache.getRequestUUIDBySelfLink(href)), + this.objectCache.getRequestUUIDBySelfLink(href) + ) ); const requestEntry$ = observableRace( href$.pipe(getRequestFromRequestHref(this.requestService)), - requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)), - ).pipe( - distinctUntilKeyChanged('lastUpdated') - ); + requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)) + ).pipe(distinctUntilKeyChanged('lastUpdated')); - const payload$ = this.buildPayload(requestEntry$, href$, ...linksToFollow); + const payload$ = this.buildPayload( + requestEntry$, + href$, + ...linksToFollow + ); return this.toRemoteDataObservable(requestEntry$, payload$); } @@ -228,19 +305,23 @@ export class RemoteDataBuildService { * @param href$ Observable href of object we want to retrieve * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - buildSingle(href$: string | Observable, ...linksToFollow: FollowLinkConfig[]): Observable> { + buildSingle( + href$: string | Observable, + ...linksToFollow: FollowLinkConfig[] + ): Observable> { return this.buildFromHref(href$, ...linksToFollow); } - toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { - return observableCombineLatest([ - requestEntry$, - payload$ - ]).pipe( - filter(([entry,payload]: [RequestEntry, T]) => - hasValue(entry) && - // filter out cases where the state is successful, but the payload isn't yet set - !(hasSucceeded(entry.state) && isUndefined(payload)) + toRemoteDataObservable( + requestEntry$: Observable, + payload$: Observable + ) { + return observableCombineLatest([requestEntry$, payload$]).pipe( + filter( + ([entry, payload]: [RequestEntry, T]) => + hasValue(entry) && + // filter out cases where the state is successful, but the payload isn't yet set + !(hasSucceeded(entry.state) && isUndefined(payload)) ), map(([entry, payload]: [RequestEntry, T]) => { let response = entry.response; @@ -270,8 +351,14 @@ export class RemoteDataBuildService { * @param href$ Observable href of objects we want to retrieve * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - buildList(href$: string | Observable, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.buildFromHref>(href$, followLink('page', { shouldEmbed: false }, ...linksToFollow)); + buildList( + href$: string | Observable, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + return this.buildFromHref>( + href$, + followLink('page', { shouldEmbed: false }, ...linksToFollow) + ); } /** @@ -284,8 +371,9 @@ export class RemoteDataBuildService { * * @param input the array of RemoteData observables to start from */ - aggregate(input: Observable>[]): Observable> { - + aggregate( + input: Observable>[] + ): Observable> { if (isEmpty(input)) { return createSuccessfulRemoteDataObject$([], new Date().getTime()); } @@ -294,15 +382,21 @@ export class RemoteDataBuildService { map((arr) => { const timeCompleted = arr .map((d: RemoteData) => d.timeCompleted) - .reduce((max: number, current: number) => current > max ? current : max); + .reduce((max: number, current: number) => + current > max ? current : max + ); const msToLive = arr .map((d: RemoteData) => d.msToLive) - .reduce((min: number, current: number) => current < min ? current : min); + .reduce((min: number, current: number) => + current < min ? current : min + ); const lastUpdated = arr .map((d: RemoteData) => d.lastUpdated) - .reduce((max: number, current: number) => current > max ? current : max); + .reduce((max: number, current: number) => + current > max ? current : max + ); let state: RequestEntryState; if (arr.some((d: RemoteData) => d.isRequestPending)) { @@ -325,11 +419,13 @@ export class RemoteDataBuildService { if (hasValue(e)) { return `[${idx}]: ${e}`; } - }).filter((e: string) => hasValue(e)) + }) + .filter((e: string) => hasValue(e)) .join(', '); - const statusCodes = new Set(arr - .map((d: RemoteData) => d.statusCode)); + const statusCodes = new Set( + arr.map((d: RemoteData) => d.statusCode) + ); let statusCode: number; @@ -350,6 +446,7 @@ export class RemoteDataBuildService { payload, statusCode ); - })); + }) + ); } } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 24f230b0e70..b656ee6eb1e 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -70,6 +70,7 @@ import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { MetadataField } from './metadata/metadata-field.model'; +import { MetadataBitstream } from './metadata/metadata-bitstream.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; import { MetadataService } from './metadata/metadata.service'; import { RegistryService } from './registry/registry.service'; @@ -138,6 +139,7 @@ import { SiteAdministratorGuard } from './data/feature-authorization/feature-aut import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { MetadataBitstreamDataService } from './data/metadata-bitstream-data.service'; import { DsDynamicTypeBindRelationService } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; @@ -292,6 +294,7 @@ const PROVIDERS = [ SiteRegisterGuard, MetadataSchemaDataService, MetadataFieldDataService, + MetadataBitstreamDataService, TokenResponseParsingService, ReloadGuard, EndUserAgreementCurrentUserGuard, @@ -328,6 +331,7 @@ export const models = ResourcePolicy, MetadataSchema, MetadataField, + MetadataBitstream, License, WorkflowItem, WorkspaceItem, diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 6bad02e7761..ce103069ba8 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -457,7 +457,7 @@ export abstract class DataService implements UpdateDa * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved - * @return {Observable>} + * @return {Observable>>} * Return an observable that emits response from the server */ searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { diff --git a/src/app/core/data/metadata-bitstream-data.service.ts b/src/app/core/data/metadata-bitstream-data.service.ts new file mode 100644 index 00000000000..7e583eb99bf --- /dev/null +++ b/src/app/core/data/metadata-bitstream-data.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; +import { hasValue } from '../../shared/empty.util'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { Observable } from 'rxjs'; +import { RequestParam } from '../cache/models/request-param.model'; +import { NoContent } from '../shared/NoContent.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { METADATA_BITSTREAM } from '../metadata/metadata-bitstream.resource-type'; +import { FindListOptions } from './request.models'; +import { dataService } from '../cache/builders/build-decorators'; +import { MetadataBitstream } from '../metadata/metadata-bitstream.model'; +import { DataService } from './data.service'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ChangeAnalyzer } from './change-analyzer'; +import { tap } from 'rxjs/operators'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint + */ +@Injectable() +@dataService(METADATA_BITSTREAM) +export class MetadataBitstreamDataService extends DataService { + protected store: Store; + protected http: HttpClient; + protected comparator: ChangeAnalyzer; + protected linkPath = 'metadatabitstreams'; + protected searchByHandleLinkPath = 'byHandle'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService + ) { + super(); + } + + /** + * Find metadata fields with either the partial metadata field name (e.g. "dc.ti") as query or an exact match to + * at least the schema, element or qualifier + * @param handle optional; an exact match of the prefix of the item identifier (e.g. "123456789/1126") + * @param fileGrpType optional; an exact match of the type of the file(e.g. "TEXT", "THUMBNAIL") + * @param options The options info used to retrieve the fields + * @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 + */ + searchByHandleParams( + handle: string, + fileGrpType: string, + options: FindListOptions = {}, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable> { + const optionParams = Object.assign(new FindListOptions(), options, { + searchParams: [ + new RequestParam('handle', hasValue(handle) ? handle : ''), + new RequestParam( + 'fileGrpType', + hasValue(fileGrpType) ? fileGrpType : '' + ), + ], + }); + return this.searchBy( + this.searchByHandleLinkPath, + optionParams, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow + ); + } +} diff --git a/src/app/core/metadata/file-info.model.ts b/src/app/core/metadata/file-info.model.ts new file mode 100644 index 00000000000..cdcccd5c97b --- /dev/null +++ b/src/app/core/metadata/file-info.model.ts @@ -0,0 +1,9 @@ +import { autoserialize, autoserializeAs } from 'cerialize'; + +export class FileInfo { + @autoserialize name: string; + @autoserialize content: any; + @autoserialize size: string; + @autoserialize isDirectory: boolean; + @autoserializeAs('sub') sub: { [key: string]: FileInfo }; +} diff --git a/src/app/core/metadata/metadata-bitstream.model.ts b/src/app/core/metadata/metadata-bitstream.model.ts new file mode 100644 index 00000000000..eb6c2d0c011 --- /dev/null +++ b/src/app/core/metadata/metadata-bitstream.model.ts @@ -0,0 +1,109 @@ +import { + autoserialize, + autoserializeAs, + deserialize, + deserializeAs, +} from 'cerialize'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { typedObject } from '../cache/builders/build-decorators'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { ResourceType } from '../shared/resource-type'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { METADATA_BITSTREAM } from './metadata-bitstream.resource-type'; +import { FileInfo } from './file-info.model'; + +/** + * Class the represents a File + */ +// export class FileInfo { +// @autoserialize name: string; +// @autoserialize content: any; +// @autoserialize size: string; +// @autoserialize isDirectory: boolean; +// @autoserializeAs('sub') sub: { [key: string]: FileInfo }; +// } + +/** + * Class that represents a MetadataBitstream + */ +@typedObject +export class MetadataBitstream extends ListableObject implements HALResource { + static type = METADATA_BITSTREAM; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this metadata field + */ + @autoserialize + id: number; + + /** + * The name of this bitstream + */ + @autoserialize + name: string; + + /** + * The description of this bitstream + */ + @autoserialize + description: string; + + /** + * The fileSize of this bitstream + */ + @autoserialize + fileSize: string; + + /** + * The checksum of this bitstream + */ + @autoserialize + checksum: string; + + /** + * The fileInfo of this bitstream + */ + @autoserializeAs(FileInfo, 'fileInfo') fileInfo: FileInfo[]; + + /** + * The format of this bitstream + */ + @autoserialize + format: string; + + /** + * The href of this bitstream + */ + @autoserialize + href: string; + + /** + * The canPreview of this bitstream + */ + @autoserialize + canPreview: boolean; + + /** + * The {@link HALLink}s for this MetadataField + */ + @deserialize + _links: { + self: HALLink; + schema: HALLink; + }; + + getRenderTypes(): (string | GenericConstructor)[] { + return [this.constructor as GenericConstructor]; + } +} +export { FileInfo }; + diff --git a/src/app/core/metadata/metadata-bitstream.resource-type.ts b/src/app/core/metadata/metadata-bitstream.resource-type.ts new file mode 100644 index 00000000000..f023a8c5259 --- /dev/null +++ b/src/app/core/metadata/metadata-bitstream.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../shared/resource-type'; + +/** + * The resource type for MetadataBitstream + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const METADATA_BITSTREAM = new ResourceType('metadatabitstream'); diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index 199f43e98e2..b3b5adc1515 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -3,7 +3,7 @@ import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable, of as observableOf, of } from 'rxjs'; import { MetadataRegistryCancelFieldAction, MetadataRegistryCancelSchemaAction, @@ -14,7 +14,7 @@ import { MetadataRegistryEditFieldAction, MetadataRegistryEditSchemaAction, MetadataRegistrySelectFieldAction, - MetadataRegistrySelectSchemaAction + MetadataRegistrySelectSchemaAction, } from '../../admin/admin-registries/metadata-registry/metadata-registry.actions'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { StoreMock } from '../../shared/testing/store.mock'; @@ -26,20 +26,24 @@ import { storeModuleConfig } from '../../app.reducer'; import { FindListOptions } from '../data/request.models'; import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'; import { MetadataFieldDataService } from '../data/metadata-field-data.service'; -import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { RemoteData } from '../data/remote-data'; import { NoContent } from '../shared/NoContent.model'; +import { MetadataBitstreamDataService } from '../data/metadata-bitstream-data.service'; @Component({ template: '' }) -class DummyComponent { -} +class DummyComponent {} describe('RegistryService', () => { let registryService: RegistryService; let mockStore; let metadataSchemaService: MetadataSchemaDataService; let metadataFieldService: MetadataFieldDataService; + let metadataBitstreamDataService: MetadataBitstreamDataService; let options: FindListOptions; let mockSchemasList: MetadataSchema[]; @@ -48,113 +52,144 @@ describe('RegistryService', () => { function init() { options = Object.assign(new FindListOptions(), { currentPage: 1, - elementsPerPage: 20 + elementsPerPage: 20, }); mockSchemasList = [ Object.assign(new MetadataSchema(), { id: 1, _links: { - self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1' } + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1', + }, }, prefix: 'dc', namespace: 'http://dublincore.org/documents/dcmi-terms/', - type: MetadataSchema.type + type: MetadataSchema.type, }), Object.assign(new MetadataSchema(), { id: 2, _links: { - self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2' } + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2', + }, }, prefix: 'mock', namespace: 'http://dspace.org/mockschema', - type: MetadataSchema.type - }) + type: MetadataSchema.type, + }), ]; mockFieldsList = [ - Object.assign(new MetadataField(), - { - id: 1, - _links: { - self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8' } + Object.assign(new MetadataField(), { + id: 1, + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8', }, - element: 'contributor', - qualifier: 'advisor', - scopeNote: null, - schema: createSuccessfulRemoteDataObject$(mockSchemasList[0]), - type: MetadataField.type - }), - Object.assign(new MetadataField(), - { - id: 2, - _links: { - self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9' } + }, + element: 'contributor', + qualifier: 'advisor', + scopeNote: null, + schema: createSuccessfulRemoteDataObject$(mockSchemasList[0]), + type: MetadataField.type, + }), + Object.assign(new MetadataField(), { + id: 2, + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9', }, - element: 'contributor', - qualifier: 'author', - scopeNote: null, - schema: createSuccessfulRemoteDataObject$(mockSchemasList[0]), - type: MetadataField.type - }), - Object.assign(new MetadataField(), - { - id: 3, - _links: { - self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10' } + }, + element: 'contributor', + qualifier: 'author', + scopeNote: null, + schema: createSuccessfulRemoteDataObject$(mockSchemasList[0]), + type: MetadataField.type, + }), + Object.assign(new MetadataField(), { + id: 3, + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10', }, - element: 'contributor', - qualifier: 'editor', - scopeNote: 'test scope note', - schema: createSuccessfulRemoteDataObject$(mockSchemasList[1]), - type: MetadataField.type - }), - Object.assign(new MetadataField(), - { - id: 4, - _links: { - self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11' } + }, + element: 'contributor', + qualifier: 'editor', + scopeNote: 'test scope note', + schema: createSuccessfulRemoteDataObject$(mockSchemasList[1]), + type: MetadataField.type, + }), + Object.assign(new MetadataField(), { + id: 4, + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11', }, - element: 'contributor', - qualifier: 'illustrator', - scopeNote: null, - schema: createSuccessfulRemoteDataObject$(mockSchemasList[1]), - type: MetadataField.type - }) + }, + element: 'contributor', + qualifier: 'illustrator', + scopeNote: null, + schema: createSuccessfulRemoteDataObject$(mockSchemasList[1]), + type: MetadataField.type, + }), ]; metadataSchemaService = jasmine.createSpyObj('metadataSchemaService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList)), + findAll: createSuccessfulRemoteDataObject$( + createPaginatedList(mockSchemasList) + ), findById: createSuccessfulRemoteDataObject$(mockSchemasList[0]), - createOrUpdateMetadataSchema: createSuccessfulRemoteDataObject$(mockSchemasList[0]), + createOrUpdateMetadataSchema: createSuccessfulRemoteDataObject$( + mockSchemasList[0] + ), delete: createNoContentRemoteDataObject$(), - clearRequests: observableOf('href') + clearRequests: observableOf('href'), }); metadataFieldService = jasmine.createSpyObj('metadataFieldService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(mockFieldsList)), + findAll: createSuccessfulRemoteDataObject$( + createPaginatedList(mockFieldsList) + ), findById: createSuccessfulRemoteDataObject$(mockFieldsList[0]), create: createSuccessfulRemoteDataObject$(mockFieldsList[0]), put: createSuccessfulRemoteDataObject$(mockFieldsList[0]), delete: createNoContentRemoteDataObject$(), - clearRequests: observableOf('href') + clearRequests: observableOf('href'), }); + metadataBitstreamDataService = jasmine.createSpyObj( + 'metadataBitstreamDataService', + { + searchByHandleParams: of({ + /* Your Mock Data */ + }), + } + ); } beforeEach(() => { init(); TestBed.configureTestingModule({ - imports: [CommonModule, StoreModule.forRoot({}, storeModuleConfig), TranslateModule.forRoot()], - declarations: [ - DummyComponent + imports: [ + CommonModule, + StoreModule.forRoot({}, storeModuleConfig), + TranslateModule.forRoot(), ], + declarations: [DummyComponent], providers: [ { provide: Store, useClass: StoreMock }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { + provide: NotificationsService, + useValue: new NotificationsServiceStub(), + }, { provide: MetadataSchemaDataService, useValue: metadataSchemaService }, { provide: MetadataFieldDataService, useValue: metadataFieldService }, - RegistryService - ] + { + provide: MetadataBitstreamDataService, + useValue: metadataBitstreamDataService, + }, + RegistryService, + ], }); registryService = TestBed.inject(RegistryService); mockStore = TestBed.inject(Store); @@ -179,12 +214,18 @@ describe('RegistryService', () => { let result; beforeEach(() => { - result = registryService.getMetadataSchemaByPrefix(mockSchemasList[0].prefix); + result = registryService.getMetadataSchemaByPrefix( + mockSchemasList[0].prefix + ); }); it('should call metadataSchemaService.findById with the correct ID', (done) => { result.subscribe(() => { - expect(metadataSchemaService.findById).toHaveBeenCalledWith(`${mockSchemasList[0].id}`, true, true); + expect(metadataSchemaService.findById).toHaveBeenCalledWith( + `${mockSchemasList[0].id}`, + true, + true + ); done(); }); }); @@ -201,7 +242,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryEditSchemaAction with the correct schema', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditSchemaAction(mockSchemasList[0])); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryEditSchemaAction(mockSchemasList[0]) + ); }); }); @@ -211,7 +254,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryCancelSchemaAction', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelSchemaAction()); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryCancelSchemaAction() + ); }); }); @@ -221,7 +266,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistrySelectSchemaAction with the correct schema', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectSchemaAction(mockSchemasList[0])); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistrySelectSchemaAction(mockSchemasList[0]) + ); }); }); @@ -231,7 +278,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryDeselectSchemaAction with the correct schema', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectSchemaAction(mockSchemasList[0])); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryDeselectSchemaAction(mockSchemasList[0]) + ); }); }); @@ -241,7 +290,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryDeselectAllSchemaAction', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllSchemaAction()); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryDeselectAllSchemaAction() + ); }); }); @@ -251,7 +302,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryEditFieldAction with the correct Field', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryEditFieldAction(mockFieldsList[0])); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryEditFieldAction(mockFieldsList[0]) + ); }); }); @@ -261,7 +314,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryCancelFieldAction', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryCancelFieldAction()); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryCancelFieldAction() + ); }); }); @@ -271,7 +326,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistrySelectFieldAction with the correct Field', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistrySelectFieldAction(mockFieldsList[0])); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistrySelectFieldAction(mockFieldsList[0]) + ); }); }); @@ -281,7 +338,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryDeselectFieldAction with the correct Field', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectFieldAction(mockFieldsList[0])); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryDeselectFieldAction(mockFieldsList[0]) + ); }); }); @@ -291,7 +350,9 @@ describe('RegistryService', () => { }); it('should dispatch a MetadataRegistryDeselectAllFieldAction', () => { - expect(mockStore.dispatch).toHaveBeenCalledWith(new MetadataRegistryDeselectAllFieldAction()); + expect(mockStore.dispatch).toHaveBeenCalledWith( + new MetadataRegistryDeselectAllFieldAction() + ); }); }); }); @@ -315,7 +376,10 @@ describe('RegistryService', () => { let result: Observable; beforeEach(() => { - result = registryService.createMetadataField(mockFieldsList[0], mockSchemasList[0]); + result = registryService.createMetadataField( + mockFieldsList[0], + mockSchemasList[0] + ); }); it('should return the created metadata field', (done) => { @@ -333,7 +397,10 @@ describe('RegistryService', () => { beforeEach(() => { metadataField = mockFieldsList[0]; metadataField.qualifier = ''; - result = registryService.createMetadataField(metadataField, mockSchemasList[0]); + result = registryService.createMetadataField( + metadataField, + mockSchemasList[0] + ); }); it('should return the created metadata field with a null qualifier', (done) => { diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 0046dbdb19c..e423c926809 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -3,7 +3,11 @@ import { Injectable } from '@angular/core'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list.model'; import { FindListOptions } from '../data/request.models'; -import { hasValue, hasValueOperator, isNotEmptyOperator } from '../../shared/empty.util'; +import { + hasValue, + hasValueOperator, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { getFirstSucceededRemoteDataPayload } from '../shared/operators'; import { createSelector, select, Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; @@ -18,7 +22,7 @@ import { MetadataRegistryEditFieldAction, MetadataRegistryEditSchemaAction, MetadataRegistrySelectFieldAction, - MetadataRegistrySelectSchemaAction + MetadataRegistrySelectSchemaAction, } from '../../admin/admin-registries/metadata-registry/metadata-registry.actions'; import { map, mergeMap, tap } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -27,29 +31,44 @@ import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'; import { MetadataFieldDataService } from '../data/metadata-field-data.service'; +import { MetadataBitstreamDataService } from '../data/metadata-bitstream-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RequestParam } from '../cache/models/request-param.model'; import { NoContent } from '../shared/NoContent.model'; +import { MetadataBitstream } from '../metadata/metadata-bitstream.model'; -const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry; -const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema); -const selectedMetadataSchemasSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.selectedSchemas); -const editMetadataFieldSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editField); -const selectedMetadataFieldsSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.selectedFields); +const metadataRegistryStateSelector = (state: AppState) => + state.metadataRegistry; +const editMetadataSchemaSelector = createSelector( + metadataRegistryStateSelector, + (metadataState: MetadataRegistryState) => metadataState.editSchema +); +const selectedMetadataSchemasSelector = createSelector( + metadataRegistryStateSelector, + (metadataState: MetadataRegistryState) => metadataState.selectedSchemas +); +const editMetadataFieldSelector = createSelector( + metadataRegistryStateSelector, + (metadataState: MetadataRegistryState) => metadataState.editField +); +const selectedMetadataFieldsSelector = createSelector( + metadataRegistryStateSelector, + (metadataState: MetadataRegistryState) => metadataState.selectedFields +); /** * Service for registry related CRUD actions such as metadata schema, metadata field and bitstream format */ @Injectable() export class RegistryService { - - constructor(private store: Store, - private notificationsService: NotificationsService, - private translateService: TranslateService, - private metadataSchemaService: MetadataSchemaDataService, - private metadataFieldService: MetadataFieldDataService) { - - } + constructor( + private store: Store, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private metadataSchemaService: MetadataSchemaDataService, + private metadataFieldService: MetadataFieldDataService, + private metadataBitstreamDataService: MetadataBitstreamDataService + ) {} /** * Retrieves all metadata schemas @@ -61,8 +80,18 @@ export class RegistryService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved */ - public getMetadataSchemas(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.metadataSchemaService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + public getMetadataSchemas( + options: FindListOptions = {}, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + return this.metadataSchemaService.findAll( + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow + ); } /** @@ -75,17 +104,32 @@ export class RegistryService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved */ - public getMetadataSchemaByPrefix(prefix: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + public getMetadataSchemaByPrefix( + prefix: string, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable> { // Temporary options to get ALL metadataschemas until there's a rest api endpoint for fetching a specific schema const options: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 10000 + elementsPerPage: 10000, }); return this.getMetadataSchemas(options).pipe( getFirstSucceededRemoteDataPayload(), map((schemas: PaginatedList) => schemas.page), isNotEmptyOperator(), - map((schemas: MetadataSchema[]) => schemas.filter((schema) => schema.prefix === prefix)[0]), - mergeMap((schema: MetadataSchema) => this.metadataSchemaService.findById(`${schema.id}`, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)) + map( + (schemas: MetadataSchema[]) => + schemas.filter((schema) => schema.prefix === prefix)[0] + ), + mergeMap((schema: MetadataSchema) => + this.metadataSchemaService.findById( + `${schema.id}`, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow + ) + ) ); } @@ -100,8 +144,49 @@ export class RegistryService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved */ - public getMetadataFieldsBySchema(schema: MetadataSchema, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.metadataFieldService.findBySchema(schema, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + public getMetadataFieldsBySchema( + schema: MetadataSchema, + options: FindListOptions = {}, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + return this.metadataFieldService.findBySchema( + schema, + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow + ); + } + + /** + * retrieves all metadatabistream that belong to a certain metadata + * @param schema The schema to filter by + * @param options The options info used to retrieve the fields + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public getMetadataBitstream( + handle: string, + fileGrpType: string, + options: FindListOptions = {}, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable> { + return this.metadataBitstreamDataService.searchByHandleParams( + handle, + fileGrpType, + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow + ); } public editMetadataSchema(schema: MetadataSchema) { @@ -212,13 +297,17 @@ export class RegistryService { * - On update, a PutRequest is used * @param schema The MetadataSchema to create or update */ - public createOrUpdateMetadataSchema(schema: MetadataSchema): Observable { + public createOrUpdateMetadataSchema( + schema: MetadataSchema + ): Observable { const isUpdate = hasValue(schema.id); return this.metadataSchemaService.createOrUpdateMetadataSchema(schema).pipe( getFirstSucceededRemoteDataPayload(), hasValueOperator(), tap(() => { - this.showNotifications(true, isUpdate, false, { prefix: schema.prefix }); + this.showNotifications(true, isUpdate, false, { + prefix: schema.prefix, + }); }) ); } @@ -244,17 +333,24 @@ export class RegistryService { * @param field The MetadataField to create * @param schema The MetadataSchema to create the field in */ - public createMetadataField(field: MetadataField, schema: MetadataSchema): Observable { + public createMetadataField( + field: MetadataField, + schema: MetadataSchema + ): Observable { if (!field.qualifier) { field.qualifier = null; } - return this.metadataFieldService.create(field, new RequestParam('schemaId', schema.id)).pipe( - getFirstSucceededRemoteDataPayload(), - hasValueOperator(), - tap(() => { - this.showNotifications(true, false, true, { field: field.toString() }); - }) - ); + return this.metadataFieldService + .create(field, new RequestParam('schemaId', schema.id)) + .pipe( + getFirstSucceededRemoteDataPayload(), + hasValueOperator(), + tap(() => { + this.showNotifications(true, false, true, { + field: field.toString(), + }); + }) + ); } /** @@ -290,13 +386,23 @@ export class RegistryService { this.metadataFieldService.clearRequests(); } - private showNotifications(success: boolean, edited: boolean, isField: boolean, options: any) { + private showNotifications( + success: boolean, + edited: boolean, + isField: boolean, + options: any + ) { const prefix = 'admin.registries.schema.notification'; const suffix = success ? 'success' : 'failure'; const editedString = edited ? 'edited' : 'created'; const messages = observableCombineLatest( - this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), - this.translateService.get(`${prefix}${isField ? '.field' : ''}.${editedString}`, options) + this.translateService.get( + success ? `${prefix}.${suffix}` : `${prefix}.${suffix}` + ), + this.translateService.get( + `${prefix}${isField ? '.field' : ''}.${editedString}`, + options + ) ); messages.subscribe(([head, content]) => { if (success) { @@ -321,7 +427,23 @@ export class RegistryService { * {@link HALLink}s should be automatically resolved * @returns an observable that emits a remote data object with a page of metadata fields that match the query */ - queryMetadataFields(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.metadataFieldService.searchByFieldNameParams(null, null, null, query, null, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + queryMetadataFields( + query: string, + options: FindListOptions = {}, + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + return this.metadataFieldService.searchByFieldNameParams( + null, + null, + null, + query, + null, + options, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow + ); } } diff --git a/src/app/core/shared/clarin/constants.ts b/src/app/core/shared/clarin/constants.ts index 5dc8e9be50f..45ef74a9ed8 100644 --- a/src/app/core/shared/clarin/constants.ts +++ b/src/app/core/shared/clarin/constants.ts @@ -6,4 +6,5 @@ export const DOWNLOAD_TOKEN_EXPIRED_EXCEPTION = 'DownloadTokenExpiredException'; export const HTTP_STATUS_UNAUTHORIZED = 401; export const USER_WITHOUT_EMAIL_EXCEPTION = 'UserWithoutEmailException'; export const MISSING_HEADERS_FROM_IDP_EXCEPTION = 'MissingHeadersFromIpd'; +export const BASE_LOCAL_URL = 'http://localhost:8080'; diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 41236d04767..5b695607b7c 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { ItemDataService } from '../../core/data/item-data.service'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { TruncatePipe } from '../../shared/utils/truncate.pipe'; @@ -11,13 +11,20 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { VarDirective } from '../../shared/utils/var.directive'; import { RouterTestingModule } from '@angular/router/testing'; import { Item } from '../../core/shared/item.model'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, of } from 'rxjs'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { AuthService } from '../../core/auth/auth.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service'; +import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service'; +import { MetadataBitstreamDataService } from 'src/app/core/data/metadata-bitstream-data.service'; +import { getMockTranslateService } from 'src/app/shared/mocks/translate.service.mock'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -41,14 +48,13 @@ const metadataServiceStub = { describe('FullItemPageComponent', () => { let comp: FullItemPageComponent; let fixture: ComponentFixture; - + let registryService: RegistryService; + let translateService: TranslateService; let authService: AuthService; let routeStub: ActivatedRouteStub; let routeData; const authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']); - - beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), @@ -63,6 +69,11 @@ describe('FullItemPageComponent', () => { data: observableOf(routeData) }); + const mockMetadataBitstreamDataService = { + searchByHandleParams: () => of({}) // Returns a mock Observable + }; + + translateService = getMockTranslateService(); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -77,6 +88,12 @@ describe('FullItemPageComponent', () => { { provide: MetadataService, useValue: metadataServiceStub }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: MetadataBitstreamDataService, useValue: mockMetadataBitstreamDataService }, + { provide: Store, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: MetadataSchemaDataService, useValue: {} }, + { provide: MetadataFieldDataService, useValue: {} }, + RegistryService ], schemas: [NO_ERRORS_SCHEMA] @@ -86,6 +103,7 @@ describe('FullItemPageComponent', () => { })); beforeEach(waitForAsync(() => { + registryService = TestBed.inject(RegistryService); fixture = TestBed.createComponent(FullItemPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 369769c77d1..c549b3678f4 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -16,6 +16,7 @@ import { hasValue } from '../../shared/empty.util'; import { AuthService } from '../../core/auth/auth.service'; import { Location } from '@angular/common'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { RegistryService } from 'src/app/core/registry/registry.service'; /** @@ -48,8 +49,9 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, items: ItemDataService, authService: AuthService, authorizationService: AuthorizationDataService, + protected registryService: RegistryService, private _location: Location) { - super(route, router, items, authService, authorizationService); + super(route, router, items, authService, authorizationService, registryService); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index d7f9f9bdfaf..e98b87d2205 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -48,12 +48,15 @@ import { ChartsModule } from 'ng2-charts'; import { ClarinGenericItemFieldComponent } from './simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component'; import { ClarinCollectionsItemFieldComponent } from './simple/field-components/clarin-collections-item-field/clarin-collections-item-field.component'; import { ClarinFilesItemFieldComponent } from './simple/field-components/clarin-files-item-field/clarin-files-item-field.component'; - +import { FileDescriptionComponent } from './simple/field-components/preview-section/file-description/file-description.component'; +import { FileTreeViewComponent } from './simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component'; +import { PreviewSectionComponent } from './simple/field-components/preview-section/preview-section.component'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator PublicationComponent, - UntypedItemComponent + UntypedItemComponent, ]; const DECLARATIONS = [ @@ -93,7 +96,10 @@ const DECLARATIONS = [ ClarinStatisticsButtonComponent, ClarinGenericItemFieldComponent, ClarinCollectionsItemFieldComponent, - ClarinFilesItemFieldComponent + ClarinFilesItemFieldComponent, + PreviewSectionComponent, + FileDescriptionComponent, + FileTreeViewComponent, ]; @NgModule({ @@ -106,15 +112,11 @@ const DECLARATIONS = [ JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), NgxGalleryModule, - ChartsModule + ChartsModule, + NgbModule ], - declarations: [ - ...DECLARATIONS, - VersionedItemComponent - ], - exports: [ - ...DECLARATIONS - ] + declarations: [...DECLARATIONS, VersionedItemComponent], + exports: [...DECLARATIONS], }) export class ItemPageModule { /** @@ -124,8 +126,7 @@ export class ItemPageModule { static withEntryComponents() { return { ngModule: ItemPageModule, - providers: ENTRY_COMPONENTS.map((component) => ({provide: component})) + providers: ENTRY_COMPONENTS.map((component) => ({ provide: component })), }; } - } diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html new file mode 100644 index 00000000000..0fb29830901 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html @@ -0,0 +1,109 @@ +
+
+ +
+
+
+
Name
+
+ {{ fileInput.name }} +
+
Size
+
+ {{ fileInput.fileSize }} +
+
Format
+
+ {{ fileInput.format }} +
+
Description
+
+ {{ fileInput.description }} +
+
MD5
+
+ {{ fileInput.checksum }} +
+
+ Preview +
+ +
+
+
+  File Preview +
+
+
    + + + + +
    {{ fileInput.fileInfo[0].content }}
    +
    + + + +
+
+
+
+
diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss new file mode 100644 index 00000000000..a561b7b903d --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss @@ -0,0 +1,113 @@ +.file-preview-box { + border: 1px solid #ddd; + padding: 4px; + margin-bottom: 10px; + + video { + margin-left: 100px; + } + + .file-content { + display: flex !important; + width: 100%; + justify-content: space-between; + + .dl-horizontal { + margin-bottom: 0; + } + + .thumbnails dl { + padding: 5px; + display: table; + } + + @media (min-width: 768px){ + .dl-horizontal dt { + float: left; + width: 160px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @media (min-width: 768px) { + .dl-horizontal dd { + margin-left: 180px; + } + } + + + .preview-image { + width: 10%; + height: 10%; + } + + } + + .button-container { + a { + text-decoration: none; + } + .download-btn, .preview-btn { + display: inline; + padding: .2em .6em .3em; + font-size: 14px; + font-weight: bold; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; + border: none; + color: white; + cursor: pointer; + background-color: #5bc0de; + } + + .download-btn:hover, .preview-btn:hover { + background-color: #31b0d5; + } + } + + .panel { + background-color: #fff; + border: 1px solid transparent; + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0,0,0,0.05); + } + + .panel-info { + border-color: #bce8f1; + } + + .panel-info>.panel-heading { + color: #3a87ad; + background-color: #d9edf7; + border-color: #bce8f1; + } + + .panel-body { + padding: 15px; + + pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.428571429; + color: #333; + white-space: pre-wrap; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; + } + } + + .pull-right { + float: right !important; + } +} \ No newline at end of file diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts new file mode 100644 index 00000000000..46586a7ce43 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { ResourceType } from 'src/app/core/shared/resource-type'; +import { FileDescriptionComponent } from './file-description.component'; + +describe('FileDescriptionComponent', () => { + let component: FileDescriptionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FileDescriptionComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileDescriptionComponent); + component = fixture.componentInstance; + + // Mock the input value + const fileInput = new MetadataBitstream(); + fileInput.id = 123; + fileInput.name = 'testFile'; + fileInput.description = 'test description'; + fileInput.fileSize = '5MB'; + fileInput.checksum = 'abc'; + fileInput.type = new ResourceType('item'); + fileInput.fileInfo = []; + fileInput.format = 'application/pdf'; + fileInput.canPreview = false; + fileInput._links = { + self: { href: '' }, + schema: { href: '' }, + }; + + component.fileInput = fileInput; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the file name', () => { + const fileNameElement = fixture.debugElement.query( + By.css('.file-content dd') + ).nativeElement; + expect(fileNameElement.textContent).toContain('testFile'); + }); +}); diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts new file mode 100644 index 00000000000..b893b1f4346 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts @@ -0,0 +1,20 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { BASE_LOCAL_URL } from 'src/app/core/shared/clarin/constants'; + +@Component({ + selector: 'ds-file-description', + templateUrl: './file-description.component.html', + styleUrls: ['./file-description.component.scss'], +}) +export class FileDescriptionComponent implements OnInit { + @Input() + fileInput: MetadataBitstream; + + ngOnInit(): void { + console.log(this.fileInput); + } + public downloadFiles() { + window.location.href = `${BASE_LOCAL_URL}${this.fileInput.href}`; + } +} diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.html b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.html new file mode 100644 index 00000000000..55d2b8e833d --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.html @@ -0,0 +1,28 @@ +
  • + + + {{ node.name }} + + + + + {{ node.name }}{{ node.size }} + + +
      + +
    +
  • \ No newline at end of file diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.scss b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.scss new file mode 100644 index 00000000000..53ef730cabb --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.scss @@ -0,0 +1,31 @@ +.foldername:before { + font-family: FontAwesome; + content: '\f07b'; + margin-right: 5px; + color: #a0a0a0; +} + +.filename:before { + font-family: FontAwesome; + content: '\f016'; + margin-right: 5px; + color: #a0a0a0; +} + + li { + padding: 0 0 0 8px; + margin: 0; + list-style: none; +} + +.pull-right { + float: right !important; +} + +.foldername a { + cursor: pointer; +} + +.foldername a:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.spec.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.spec.ts new file mode 100644 index 00000000000..606370da57a --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FileInfo, MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { FileTreeViewComponent } from './file-tree-view.component'; + +describe('FileTreeViewComponent', () => { + let component: FileTreeViewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FileTreeViewComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileTreeViewComponent); + component = fixture.componentInstance; + + // Mock the node input value + const fileInfo = new FileInfo(); + fileInfo.name = 'TestFolder'; + fileInfo.isDirectory = true; + fileInfo.size = null; + fileInfo.content = null; // add content property + fileInfo.sub = { + 'TestSubFolder': { + name: 'TestSubFolder', + isDirectory: true, + size: null, + content: null, // add content property + sub: null + } + }; + + const metadataBitstream = new MetadataBitstream(); + metadataBitstream.fileInfo = [fileInfo]; + component.node = fileInfo; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the node name', () => { + const nodeNameElement = fixture.debugElement.query(By.css('.foldername')).nativeElement; + expect(nodeNameElement.textContent).toContain('TestFolder'); + }); + + it('should correctly get the keys of the sub object', () => { + expect(component.getKeys(component.node.sub)).toEqual(['TestSubFolder']); + }); +}); diff --git a/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.ts b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.ts new file mode 100644 index 00000000000..96129517885 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { FileInfo } from 'src/app/core/metadata/metadata-bitstream.model'; + +@Component({ + selector: 'ds-file-tree-view', + templateUrl: './file-tree-view.component.html', + styleUrls: ['./file-tree-view.component.scss'], +}) +export class FileTreeViewComponent { + @Input() + node: FileInfo; + + isCollapsed = false; + + getKeys(obj: any): string[] { + return Object.keys(obj); + } +} diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.html b/src/app/item-page/simple/field-components/preview-section/preview-section.component.html new file mode 100644 index 00000000000..37ba7d1ce28 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.html @@ -0,0 +1,3 @@ +
    + +
    \ No newline at end of file diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.scss b/src/app/item-page/simple/field-components/preview-section/preview-section.component.scss new file mode 100644 index 00000000000..e4267ba0874 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.scss @@ -0,0 +1,107 @@ +.file-preview-box { + border: 1px solid #ddd; + padding: 4px; + + .file-content { + display: flex !important; + width: 100%; + justify-content: space-between; + + .dl-horizontal { + margin-bottom: 0; + } + + .thumbnails dl { + padding: 5px; + display: table; + } + + @media (min-width: 768px){ + .dl-horizontal dt { + float: left; + width: 160px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @media (min-width: 768px) { + .dl-horizontal dd { + margin-left: 180px; + } + } + + + .preview-image { + width: 10%; + height: 10%; + } + + } + + .button-container { + .download-btn, .preview-btn { + display: inline; + padding: .2em .6em .3em; + font-size: 12px; + font-weight: bold; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; + border: none; + color: white; + cursor: pointer; + background-color: #5bc0de; + } + } + + .panel { + background-color: #fff; + border: 1px solid transparent; + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0,0,0,0.05); + } + + .panel-info { + border-color: #bce8f1; + } + + .panel-info>.panel-heading { + color: #3a87ad; + background-color: #d9edf7; + border-color: #bce8f1; + } + + .treeview .foldername:before { + font-family: FontAwesome; + content: '\f07b'; + margin-right: 5px; + color: #a0a0a0; + } + + .treeview .filename:before { + font-family: FontAwesome; + content: '\f016'; + margin-right: 5px; + color: #a0a0a0; + } + + .treeview li { + padding: 0 0 0 8px; + margin: 0; + list-style: none; + } + + .pull-right { + float: right !important; + } + + .panel-body { + padding: 15px; + } +} \ No newline at end of file diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts b/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts new file mode 100644 index 00000000000..8258c340d86 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BehaviorSubject, of } from 'rxjs'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { RegistryService } from 'src/app/core/registry/registry.service'; + +import { PreviewSectionComponent } from './preview-section.component'; +import { ResourceType } from 'src/app/core/shared/resource-type'; +import { HALLink } from 'src/app/core/shared/hal-link.model'; +import { Item } from 'src/app/core/shared/item.model'; + +describe('PreviewSectionComponent', () => { + let component: PreviewSectionComponent; + let fixture: ComponentFixture; + let mockRegistryService: any; + + beforeEach(async () => { + mockRegistryService = jasmine.createSpyObj('RegistryService', [ + 'getMetadataBitstream', + ]); + + await TestBed.configureTestingModule({ + declarations: [PreviewSectionComponent], + providers: [{ provide: RegistryService, useValue: mockRegistryService }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PreviewSectionComponent); + component = fixture.componentInstance; + + // Set up the mock service's getMetadataBitstream method to return a simple stream + const metadatabitstream = new MetadataBitstream(); + metadatabitstream.id = 123; + metadatabitstream.name = 'test'; + metadatabitstream.description = 'test'; + metadatabitstream.fileSize = '1MB'; + metadatabitstream.checksum = 'abc'; + metadatabitstream.type = new ResourceType('item'); + metadatabitstream.fileInfo = []; + metadatabitstream.format = 'text'; + metadatabitstream.canPreview = false; + metadatabitstream._links = { + self: new HALLink(), + schema: new HALLink(), + }; + + metadatabitstream._links.self.href = ''; + metadatabitstream._links.schema.href = ''; + const metadataBitstreams: MetadataBitstream[] = [metadatabitstream]; + const bitstreamStream = new BehaviorSubject(metadataBitstreams); + mockRegistryService.getMetadataBitstream.and.returnValue( + of(bitstreamStream) + ); + + component.item = new Item(); + component.item.handle = '12345'; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call getMetadataBitstream on init', () => { + expect(mockRegistryService.getMetadataBitstream).toHaveBeenCalled(); + }); + + it('should set listOfFiles on init', (done) => { + component.listOfFiles.subscribe((files) => { + expect(files).toEqual([]); + done(); + }); + }); +}); diff --git a/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts b/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts new file mode 100644 index 00000000000..24e9dcc5580 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts @@ -0,0 +1,30 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { Item } from 'src/app/core/shared/item.model'; +import { getAllSucceededRemoteListPayload } from 'src/app/core/shared/operators'; + +@Component({ + selector: 'ds-preview-section', + templateUrl: './preview-section.component.html', + styleUrls: ['./preview-section.component.scss'], +}) +export class PreviewSectionComponent implements OnInit { + @Input() item: Item; + + listOfFiles: BehaviorSubject = new BehaviorSubject< + MetadataBitstream[] + >([] as any); + + constructor(protected registryService: RegistryService) {} // Modified + + ngOnInit(): void { + this.registryService + .getMetadataBitstream(this.item.handle, 'ORIGINAL,TEXT,THUMBNAIL') + .pipe(getAllSucceededRemoteListPayload()) + .subscribe((data: MetadataBitstream[]) => { + this.listOfFiles.next(data); + }); + } +} diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index c4c245e5604..65fc101db43 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -1,18 +1,75 @@ -
    -
    -
    +
    +
    +
    - - - + + +

     Files in this item

    +  Download instructions for command line +
    + +
    {{
    +          command
    +        }}
    +
    + +  Download all files in item ({{ totalFileSizes }}) + +
    - - + +
    diff --git a/src/app/item-page/simple/item-page.component.scss b/src/app/item-page/simple/item-page.component.scss index f28ce613865..d86eec4771f 100644 --- a/src/app/item-page/simple/item-page.component.scss +++ b/src/app/item-page/simple/item-page.component.scss @@ -27,3 +27,58 @@ .clarin-cut-top-1 { margin-top: -16px; } + +.btn-download{ + color: #fff !important; + background-color: #428bca; + border-color: #357ebd; + cursor: pointer; +} + +.btn-download:hover { + color: #fff; + background-color: #3276b1; + border-color: #285e8e; +} + +pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.428571429; + color: #333; + word-break: break-all; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; + white-space: pre-wrap; +} + +#command-div .repo-copy-btn { + opacity: 0; + -webkit-transition: opacity .3s ease-in-out; + -o-transition: opacity .3s ease-in-out; + transition: opacity .3s ease-in-out; +} + +.repo-copy-btn { + width: 20px; + height: 20px; + position: relative; + display: inline-block; + padding: 0 !important; +} + +.repo-copy-btn:before { + content: " "; + background-image: url('https://lindat.mff.cuni.cz/repository/xmlui/themes/UFAL/images/clippy.svg'); + background-size: 13px 15px; + background-repeat: no-repeat; + background-color: red; + display: inline-block; + width: 13px; + height: 15px; + padding: 0 !important; +} diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index 387bddf1736..e266013a0dd 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ItemDataService } from '../../core/data/item-data.service'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; @@ -12,52 +12,71 @@ import { Item } from '../../core/shared/item.model'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { createRelationshipsObservable } from './item-types/shared/item.component.spec'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, of } from 'rxjs'; import { createFailedRemoteDataObject$, createPendingRemoteDataObject$, createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ + createSuccessfulRemoteDataObject$, } from '../../shared/remote-data.utils'; import { AuthService } from '../../core/auth/auth.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service'; +import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service'; +import { MetadataBitstreamDataService } from 'src/app/core/data/metadata-bitstream-data.service'; +import { getMockTranslateService } from 'src/app/shared/mocks/translate.service.mock'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), metadata: [], - relationships: createRelationshipsObservable() + relationships: createRelationshipsObservable(), }); describe('ItemPageComponent', () => { let comp: ItemPageComponent; let fixture: ComponentFixture; let authService: AuthService; - const authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']); + let translateService: TranslateService; + let registryService: RegistryService; + const authorizationService = jasmine.createSpyObj('authorizationService', [ + 'isAuthorized', + ]); const mockMetadataService = { /* tslint:disable:no-empty */ - processRemoteData: () => { - } + processRemoteData: () => {}, /* tslint:enable:no-empty */ }; const mockRoute = Object.assign(new ActivatedRouteStub(), { - data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }) + data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }), }); + const mockMetadataBitstreamDataService = { + searchByHandleParams: () => of({}) // Returns a mock Observable + }; + beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), - setRedirectUrl: {} + setRedirectUrl: {}, }); + translateService = getMockTranslateService(); + TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), BrowserAnimationsModule], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + ], declarations: [ItemPageComponent, VarDirective], providers: [ { provide: ActivatedRoute, useValue: mockRoute }, @@ -66,15 +85,24 @@ describe('ItemPageComponent', () => { { provide: Router, useValue: {} }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: Store, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: MetadataSchemaDataService, useValue: {} }, + { provide: MetadataFieldDataService, useValue: {} }, + { provide: MetadataBitstreamDataService, useValue: mockMetadataBitstreamDataService }, + RegistryService, ], - schemas: [NO_ERRORS_SCHEMA] - }).overrideComponent(ItemPageComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default } - }).compileComponents(); + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(ItemPageComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents(); })); beforeEach(waitForAsync(() => { + registryService = TestBed.inject(RegistryService); fixture = TestBed.createComponent(ItemPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); @@ -104,5 +132,4 @@ describe('ItemPageComponent', () => { expect(error.nativeElement).toBeDefined(); }); }); - }); diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index 41f4b945822..96871ae48e0 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,21 +1,28 @@ -import {map, take} from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Observable} from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { fadeInOut } from '../../shared/animations/fade'; -import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; +import { + getAllSucceededRemoteDataPayload, + getAllSucceededRemoteListPayload, + redirectOn4xx, +} from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; import { AuthService } from '../../core/auth/auth.service'; import { getItemPageRoute } from '../item-page-routing-paths'; import { isNotEmpty } from '../../shared/empty.util'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { BASE_LOCAL_URL } from 'src/app/core/shared/clarin/constants'; /** * This component renders a simple item page. @@ -27,10 +34,9 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/ styleUrls: ['./item-page.component.scss'], templateUrl: './item-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut] + animations: [fadeInOut], }) export class ItemPageComponent implements OnInit { - /** * The item's id */ @@ -50,6 +56,22 @@ export class ItemPageComponent implements OnInit { * Route to the item's page */ itemPageRoute$: Observable; + /** + * handle of the specific item + */ + itemHandle: string; + /** + * handle of the specific item + */ + fileName: string; + /** + * determine to show download all zip button or not + */ + canDownloadAllFiles = true; + /** + * command for the download command feature + */ + command: string; /** * Whether the current user is an admin or not @@ -66,13 +88,27 @@ export class ItemPageComponent implements OnInit { */ withdrawnTombstone = false; + /** + * If download by command button is click, the command line will be shown + */ + isCommandLineVisible = false; + /** + * list of files uploaded by users to this item + */ + listOfFiles: MetadataBitstream[]; + /** + * total size of list of files uploaded by users to this item + */ + totalFileSizes: string; + constructor( protected route: ActivatedRoute, private router: Router, private items: ItemDataService, private authService: AuthService, private authorizationService: AuthorizationDataService, - ) { } + protected registryService: RegistryService + ) {} /** * Initialize instance variables @@ -88,6 +124,43 @@ export class ItemPageComponent implements OnInit { ); this.showTombstone(); + + this.registryService + .getMetadataBitstream(this.itemHandle, 'ORIGINAL,TEXT,THUMBNAIL') + .pipe(getAllSucceededRemoteListPayload()) + .subscribe((data: MetadataBitstream[]) => { + this.listOfFiles = data; + this.generateCurlCommand(); + this.sumFileSizes(); + }); + } + + sumFileSizes() { + const sizeUnits = { + B: 1, + KB: 1000, + MB: 1000 * 1000, + GB: 1000 * 1000 * 1000, + TB: 1000 * 1000 * 1000 * 1000, + }; + + let totalBytes = this.listOfFiles.reduce((total, file) => { + const [valueStr, unit] = file.fileSize.split(' '); + const value = parseFloat(valueStr); + const bytes = value * sizeUnits[unit.toUpperCase()]; + return total + bytes; + }, 0); + + let finalUnit = 'B'; + for (const unit of ['KB', 'MB', 'GB', 'TB']) { + if (totalBytes < 1000) { + break; + } + totalBytes /= 1000; + finalUnit = unit; + } + + this.totalFileSizes = totalBytes.toFixed(2) + ' ' + finalUnit; } showTombstone() { @@ -97,10 +170,10 @@ export class ItemPageComponent implements OnInit { let isReplaced = ''; // load values from item - this.itemRD$.pipe( - take(1), - getAllSucceededRemoteDataPayload()) + this.itemRD$ + .pipe(take(1), getAllSucceededRemoteDataPayload()) .subscribe((item: Item) => { + this.itemHandle = item.handle; isWithdrawn = item.isWithdrawn; isReplaced = item.metadata['dc.relation.isreplacedby']?.[0]?.value; }); @@ -112,8 +185,10 @@ export class ItemPageComponent implements OnInit { // for users navigate to the custom tombstone // for admin stay on the item page with tombstone flag - this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); - this.isAdmin$.subscribe(isAdmin => { + this.isAdmin$ = this.authorizationService.isAuthorized( + FeatureID.AdministratorOf + ); + this.isAdmin$.subscribe((isAdmin) => { // do not show tombstone for admin but show it for users if (!isAdmin) { if (isNotEmpty(isReplaced)) { @@ -124,4 +199,26 @@ export class ItemPageComponent implements OnInit { } }); } + + setCommandline() { + this.isCommandLineVisible = !this.isCommandLineVisible; + } + + generateCurlCommand() { + const fileNames = this.listOfFiles.map((file: MetadataBitstream) => { + if (!file.canPreview) { + this.canDownloadAllFiles = file.canPreview; + } + + return file.name; + }); + + this.command = `curl --remote-name-all ${BASE_LOCAL_URL}/server/bitstream/handle/${ + this.itemHandle + }/{${fileNames.join(',')}}`; + } + + downloadFiles() { + window.location.href = `${BASE_LOCAL_URL}/server/bitstream/allzip?handleId=${this.itemHandle}`; + } } diff --git a/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts index b4c3da2cdc3..33a97493358 100644 --- a/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts +++ b/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts @@ -1,4 +1,8 @@ -import { combineLatest as observableCombineLatest, Observable, zip as observableZip } from 'rxjs'; +import { + combineLatest as observableCombineLatest, + Observable, + zip as observableZip, +} from 'rxjs'; import { distinctUntilChanged, map, mergeMap, switchMap } from 'rxjs/operators'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -6,14 +10,20 @@ import { Relationship } from '../../../../core/shared/item-relationships/relatio import { Item } from '../../../../core/shared/item.model'; import { getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteData + getFirstSucceededRemoteData, } from '../../../../core/shared/operators'; import { hasValue } from '../../../../shared/empty.util'; import { InjectionToken } from '@angular/core'; -export const PAGINATED_RELATIONS_TO_ITEMS_OPERATOR = new InjectionToken<(thisId: string) => (source: Observable>>) => Observable>>>('paginatedRelationsToItems', { +export const PAGINATED_RELATIONS_TO_ITEMS_OPERATOR = new InjectionToken< + ( + thisId: string + ) => ( + source: Observable>> + ) => Observable>> +>('paginatedRelationsToItems', { providedIn: 'root', - factory: () => paginatedRelationsToItems + factory: () => paginatedRelationsToItems, }); /** @@ -23,42 +33,51 @@ export const PAGINATED_RELATIONS_TO_ITEMS_OPERATOR = new InjectionToken<(thisId: * For example: "(o) => o.id" will compare the two arrays by comparing their content by id. * @param mapFn Function for mapping the arrays */ -export const compareArraysUsing = (mapFn: (t: T) => any) => +export const compareArraysUsing = + (mapFn: (t: T) => any) => (a: T[], b: T[]): boolean => { - if (!Array.isArray(a) || ! Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) { return false; } const aIds = a.map(mapFn); const bIds = b.map(mapFn); - return aIds.length === bIds.length && + return ( + aIds.length === bIds.length && aIds.every((e) => bIds.includes(e)) && - bIds.every((e) => aIds.includes(e)); + bIds.every((e) => aIds.includes(e)) + ); }; /** * Operator for comparing arrays using the object's ids */ export const compareArraysUsingIds = () => - compareArraysUsing((t: T) => hasValue(t) ? t.id : undefined); + compareArraysUsing((t: T) => (hasValue(t) ? t.id : undefined)); /** * Operator for turning a list of relationships into a list of the relevant items * @param {string} thisId The item's id of which the relations belong to * @returns {(source: Observable) => Observable} */ -export const relationsToItems = (thisId: string) => +export const relationsToItems = + (thisId: string) => (source: Observable): Observable => source.pipe( mergeMap((rels: Relationship[]) => observableZip( - ...rels.map((rel: Relationship) => observableCombineLatest(rel.leftItem, rel.rightItem)) + ...rels.map((rel: Relationship) => + observableCombineLatest(rel.leftItem, rel.rightItem) + ) ) ), map((arr) => arr - .filter(([leftItem, rightItem]) => leftItem.hasSucceeded && rightItem.hasSucceeded) + .filter( + ([leftItem, rightItem]) => + leftItem.hasSucceeded && rightItem.hasSucceeded + ) .map(([leftItem, rightItem]) => { if (leftItem.payload.id === thisId) { return rightItem.payload; @@ -68,7 +87,7 @@ export const relationsToItems = (thisId: string) => }) .filter((item: Item) => hasValue(item)) ), - distinctUntilChanged(compareArraysUsingIds()), + distinctUntilChanged(compareArraysUsingIds()) ); /** @@ -77,8 +96,11 @@ export const relationsToItems = (thisId: string) => * @param {string} thisId The item's id of which the relations belong to * @returns {(source: Observable) => Observable} */ -export const paginatedRelationsToItems = (thisId: string) => - (source: Observable>>): Observable>> => +export const paginatedRelationsToItems = + (thisId: string) => + ( + source: Observable>> + ): Observable>> => source.pipe( getFirstSucceededRemoteData(), switchMap((relationshipsRD: RemoteData>) => { @@ -86,9 +108,10 @@ export const paginatedRelationsToItems = (thisId: string) => relationshipsRD.payload.page.map((rel: Relationship) => observableCombineLatest([ rel.leftItem.pipe(getFirstSucceededRemoteDataPayload()), - rel.rightItem.pipe(getFirstSucceededRemoteDataPayload())] - ) - )).pipe( + rel.rightItem.pipe(getFirstSucceededRemoteDataPayload()), + ]) + ) + ).pipe( map((arr) => arr .map(([leftItem, rightItem]) => { @@ -102,7 +125,11 @@ export const paginatedRelationsToItems = (thisId: string) => ), distinctUntilChanged(compareArraysUsingIds()), map((relatedItems: Item[]) => - Object.assign(relationshipsRD, { payload: Object.assign(relationshipsRD.payload, { page: relatedItems } )}) + Object.assign(relationshipsRD, { + payload: Object.assign(relationshipsRD.payload, { + page: relatedItems, + }), + }) ) ); }) diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 3fb68ff17d8..5abd2645e4c 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -109,5 +109,6 @@

    [iconName]="'fa-paperclip'" [separator]="'
    '"> +