From 80394f10e7b67ea48ca787e9ad9d6abc87a7ee5c Mon Sep 17 00:00:00 2001 From: milanmajchrak <90026355+milanmajchrak@users.noreply.github.com> Date: Wed, 4 Oct 2023 16:08:39 +0200 Subject: [PATCH] ufal/fe-22-54-download-preview-item (#362) * create new component and add model file and service file * build the user interface and calling the api from backend * add logic for handling data from BE and refactor the UI * fix the lint error and fix the collapse/hide behaviour * add UI for download buttons and fix UI for the ZIP Preview * getting data from backend and make the download feature * handle with the dynamic input * fix the collapese/hide behaviour and handle more cases * add more conditions for checking permission to preview and download files * fix lint error * fix failed test * Lock/download preview (#311) * create new component and add model file and service file * build the user interface and calling the api from backend * add logic for handling data from BE and refactor the UI * fix the lint error and fix the collapse/hide behaviour --------- Co-authored-by: HuynhKhoa1601 * Resolved conflicts * Revert unnecessary changes * Refactoring and removed font awesome loading from node modules * Updated preview file box look and feel * Fixed file preview icons * Fixed html preview scrolling * Show scrollbar in txt preview * Some refactoring * Refactoring and the dspace BE is loaded from configuration property. * Added messages * Fixed tests * Added download buttons and fixed downloading * Added messages * Added image to the assetstore instead of url from lindat --------- Co-authored-by: HuynhKhoa1601 --- src/app/core/core.module.ts | 4 + .../data/metadata-bitstream-data.service.ts | 76 +++++++++++ src/app/core/metadata/file-info.model.ts | 12 ++ .../core/metadata/metadata-bitstream.model.ts | 97 ++++++++++++++ .../metadata-bitstream.resource-type.ts | 9 ++ .../core/registry/registry.service.spec.ts | 14 +- src/app/core/registry/registry.service.ts | 22 ++- src/app/core/shared/clarin/constants.ts | 1 - .../full/full-item-page.component.spec.ts | 67 ++++++++-- .../full/full-item-page.component.ts | 8 +- src/app/item-page/item-page.module.ts | 17 ++- .../file-description.component.html | 79 +++++++++++ .../file-description.component.scss | 125 ++++++++++++++++++ .../file-description.component.spec.ts | 87 ++++++++++++ .../file-description.component.ts | 43 ++++++ .../file-tree-view.component.html | 25 ++++ .../file-tree-view.component.scss | 31 +++++ .../file-tree-view.component.spec.ts | 57 ++++++++ .../file-tree-view.component.ts | 18 +++ .../preview-section.component.html | 3 + .../preview-section.component.scss | 108 +++++++++++++++ .../preview-section.component.spec.ts | 75 +++++++++++ .../preview-section.component.ts | 28 ++++ .../item-page/simple/item-page.component.html | 22 +++ .../item-page/simple/item-page.component.scss | 55 ++++++++ .../simple/item-page.component.spec.ts | 45 ++++++- .../item-page/simple/item-page.component.ts | 115 +++++++++++++++- .../publication/publication.component.html | 8 +- .../untyped-item/untyped-item.component.html | 8 +- .../clarin-license-resource/ufal-theme.css | 16 +-- src/assets/i18n/cs.json5 | 37 ++++++ src/assets/i18n/en.json5 | 25 ++++ src/assets/images/application-x-gzip.png | Bin 0 -> 11414 bytes 33 files changed, 1293 insertions(+), 44 deletions(-) create mode 100644 src/app/core/data/metadata-bitstream-data.service.ts create mode 100644 src/app/core/metadata/file-info.model.ts create mode 100644 src/app/core/metadata/metadata-bitstream.model.ts create mode 100644 src/app/core/metadata/metadata-bitstream.resource-type.ts create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.html create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.scss create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.spec.ts create mode 100644 src/app/item-page/simple/field-components/preview-section/file-description/file-tree-view/file-tree-view.component.ts create mode 100644 src/app/item-page/simple/field-components/preview-section/preview-section.component.html create mode 100644 src/app/item-page/simple/field-components/preview-section/preview-section.component.scss create mode 100644 src/app/item-page/simple/field-components/preview-section/preview-section.component.spec.ts create mode 100644 src/app/item-page/simple/field-components/preview-section/preview-section.component.ts create mode 100644 src/assets/images/application-x-gzip.png diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 175a3fd1609..306b950f8f4 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -64,6 +64,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'; @@ -133,6 +134,7 @@ import { 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'; @@ -298,6 +300,7 @@ const PROVIDERS = [ SiteRegisterGuard, MetadataSchemaDataService, MetadataFieldDataService, + MetadataBitstreamDataService, TokenResponseParsingService, ReloadGuard, EndUserAgreementCurrentUserGuard, @@ -341,6 +344,7 @@ export const models = ResourcePolicy, MetadataSchema, MetadataField, + MetadataBitstream, License, WorkflowItem, WorkspaceItem, 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..31249318334 --- /dev/null +++ b/src/app/core/data/metadata-bitstream-data.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { hasValue } from '../../shared/empty.util'; +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 { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { METADATA_BITSTREAM } from '../metadata/metadata-bitstream.resource-type'; +import { MetadataBitstream } from '../metadata/metadata-bitstream.model'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; +import { ChangeAnalyzer } from './change-analyzer'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { CoreState } from '../core-state.model'; +import { dataService } from './base/data-service.decorator'; +import { FindListOptions } from './find-list-options.model'; +import { linkName } from './clarin/clarin-license-data.service'; +import { PaginatedList } from './paginated-list.model'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint + */ +@Injectable() +@dataService(METADATA_BITSTREAM) +export class MetadataBitstreamDataService extends IdentifiableDataService implements SearchData { + protected store: Store; + protected http: HttpClient; + protected comparator: ChangeAnalyzer; + protected linkPath = 'metadatabitstreams'; + protected searchByHandleLinkPath = 'byHandle'; + private searchData: SearchData; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService + ) { + super(linkName, requestService, rdbService, objectCache, halService, undefined); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * 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); + } + + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, 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..c2bc189189a --- /dev/null +++ b/src/app/core/metadata/file-info.model.ts @@ -0,0 +1,12 @@ +import { autoserialize, autoserializeAs } from 'cerialize'; + +/** + * This class is used to store the information about a file or a directory + */ +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..369d3552b4a --- /dev/null +++ b/src/app/core/metadata/metadata-bitstream.model.ts @@ -0,0 +1,97 @@ +import { + autoserialize, + autoserializeAs, + deserialize, +} 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 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..6214c846169 --- /dev/null +++ b/src/app/core/metadata/metadata-bitstream.resource-type.ts @@ -0,0 +1,9 @@ +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 e9dfbe7e2c3..271f94ad00c 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, @@ -30,6 +30,7 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { RemoteData } from '../data/remote-data'; import { NoContent } from '../shared/NoContent.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { MetadataBitstreamDataService } from '../data/metadata-bitstream-data.service'; @Component({ template: '' }) class DummyComponent { @@ -40,6 +41,7 @@ describe('RegistryService', () => { let mockStore; let metadataSchemaService: MetadataSchemaDataService; let metadataFieldService: MetadataFieldDataService; + let metadataBitstreamDataService: MetadataBitstreamDataService; let options: FindListOptions; let mockSchemasList: MetadataSchema[]; @@ -139,6 +141,14 @@ describe('RegistryService', () => { delete: createNoContentRemoteDataObject$(), clearRequests: observableOf('href') }); + metadataBitstreamDataService = jasmine.createSpyObj( + 'metadataBitstreamDataService', + { + searchByHandleParams: of({ + /* Your Mock Data */ + }), + } + ); } beforeEach(() => { @@ -153,6 +163,8 @@ describe('RegistryService', () => { { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: MetadataSchemaDataService, useValue: metadataSchemaService }, { provide: MetadataFieldDataService, useValue: metadataFieldService }, + { provide: MetadataFieldDataService, useValue: metadataFieldService }, + { provide: MetadataBitstreamDataService, useValue: metadataBitstreamDataService }, RegistryService ] }); diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 7a377405bd0..ede05f718a2 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -26,10 +26,12 @@ 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 { FindListOptions } from '../data/find-list-options.model'; +import { MetadataBitstream } from '../metadata/metadata-bitstream.model'; const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry; const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema); @@ -42,12 +44,12 @@ const selectedMetadataFieldsSelector = createSelector(metadataRegistryStateSelec */ @Injectable() export class RegistryService { - constructor(private store: Store, private notificationsService: NotificationsService, private translateService: TranslateService, private metadataSchemaService: MetadataSchemaDataService, - private metadataFieldService: MetadataFieldDataService) { + private metadataFieldService: MetadataFieldDataService, + private metadataBitstreamDataService: MetadataBitstreamDataService) { } @@ -104,6 +106,22 @@ export class RegistryService { 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) { this.store.dispatch(new MetadataRegistryEditSchemaAction(schema)); } diff --git a/src/app/core/shared/clarin/constants.ts b/src/app/core/shared/clarin/constants.ts index 5dc8e9be50f..45093a4a88d 100644 --- a/src/app/core/shared/clarin/constants.ts +++ b/src/app/core/shared/clarin/constants.ts @@ -6,4 +6,3 @@ 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'; - 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 66c6488b8e4..3ab1b7b051d 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,7 +11,7 @@ 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 { BehaviorSubject, of as observableOf } from 'rxjs'; +import { BehaviorSubject, of, of as observableOf } from 'rxjs'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; @@ -20,6 +20,16 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec'; import { RemoteData } from '../../core/data/remote-data'; +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'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { cold } from 'jasmine-marbles'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -50,14 +60,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; let authorizationDataService: AuthorizationDataService; - - beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), @@ -76,6 +85,26 @@ describe('FullItemPageComponent', () => { isAuthorized: observableOf(false), }); + const mockMetadataBitstreamDataService = { + searchByHandleParams: () => of({}) // Returns a mock Observable + }; + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + + let halService: HALEndpointService; + halService = jasmine.createSpyObj('halService', { + 'getEndpoint': cold('a', { a: 'endpointURL' }) + }); + + + translateService = getMockTranslateService(); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -90,6 +119,13 @@ describe('FullItemPageComponent', () => { { provide: MetadataService, useValue: metadataServiceStub }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: MetadataBitstreamDataService, useValue: mockMetadataBitstreamDataService }, + { provide: Store, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: MetadataSchemaDataService, useValue: {} }, + { provide: MetadataFieldDataService, useValue: {} }, + { provide: HALEndpointService, useValue: halService }, + RegistryService ], schemas: [NO_ERRORS_SCHEMA] @@ -99,6 +135,7 @@ describe('FullItemPageComponent', () => { })); beforeEach(waitForAsync(() => { + registryService = TestBed.inject(RegistryService); fixture = TestBed.createComponent(FullItemPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); @@ -112,19 +149,23 @@ describe('FullItemPageComponent', () => { }); it('should show simple view button when not originated from workflow item', () => { - expect(comp.fromSubmissionObject).toBe(false); - const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); - expect(simpleViewBtn).toBeTruthy(); + waitForAsync(() => { + expect(comp.fromSubmissionObject).toBe(false); + const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); + expect(simpleViewBtn).toBeTruthy(); + }); }); it('should not show simple view button when originated from workflow', fakeAsync(() => { routeData.wfi = createSuccessfulRemoteDataObject$({ id: 'wfiId'}); comp.ngOnInit(); - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(comp.fromSubmissionObject).toBe(true); - const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); - expect(simpleViewBtn).toBeFalsy(); + waitForAsync(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(comp.fromSubmissionObject).toBe(true); + const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); + expect(simpleViewBtn).toBeFalsy(); + }); }); })); 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 118e4360048..2b60fb67003 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,8 @@ 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'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; /** @@ -48,8 +50,10 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, items: ItemDataService, authService: AuthService, authorizationService: AuthorizationDataService, - private _location: Location) { - super(route, router, items, authService, authorizationService); + protected registryService: RegistryService, + private _location: Location, + protected halService: HALEndpointService,) { + super(route, router, items, authService, authorizationService, registryService, halService); } /*** 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 6ae23988992..6e87e4594a9 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -70,7 +70,14 @@ import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/ import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { ItemSharedModule } from './item-shared.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; - +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import {PreviewSectionComponent} from './simple/field-components/preview-section/preview-section.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'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -122,7 +129,10 @@ const DECLARATIONS = [ ClarinStatisticsButtonComponent, ClarinGenericItemFieldComponent, ClarinCollectionsItemFieldComponent, - ClarinFilesItemFieldComponent + ClarinFilesItemFieldComponent, + PreviewSectionComponent, + FileDescriptionComponent, + FileTreeViewComponent, ]; @NgModule({ @@ -141,7 +151,8 @@ const DECLARATIONS = [ ResultsBackButtonModule, UploadModule, DsoPageModule, - ChartsModule + ChartsModule, + NgbModule ], declarations: [ ...DECLARATIONS, 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..640c1487eb1 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.html @@ -0,0 +1,79 @@ +
+
+ +
+
+
+
{{'item.file.description.name' | translate}}
+
+ {{ fileInput.name }} +
+
{{'item.file.description.size' | translate}}
+
+ {{ fileInput.fileSize }} +
+
{{'item.file.description.format' | translate}}
+
+ {{ fileInput.format }} +
+
{{'item.file.description.description' | translate}}
+
+ {{ fileInput.description }} +
+
{{'item.file.description.checksum' | translate}}
+
+ {{ fileInput.checksum }} +
+
+ Preview +
+ +
+
+
+   + {{'item.file.description.file.preview' | translate}} + +
+
+
    + + + + + +
    {{ 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..2611c696bc7 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.scss @@ -0,0 +1,125 @@ +.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: 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; + } + + .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; + } +} + +.dl-horizontal { + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif !important; + font-size: 14px !important; + line-height: 1.428571429 !important; + color: #333 !important; +} + +dd { + margin-bottom: 0; +} + 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..4d1d838faa9 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.spec.ts @@ -0,0 +1,87 @@ +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'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; +import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model'; +import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; +import { getMockTranslateService } from '../../../../../shared/mocks/translate.service.mock'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../../../shared/mocks/translate-loader.mock'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; + +describe('FileDescriptionComponent', () => { + let component: FileDescriptionComponent; + let fixture: ComponentFixture; + let translateService: TranslateService; + let halService: HALEndpointService; + + beforeEach(async () => { + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + + halService = jasmine.createSpyObj('authService', { + getRootHref: 'root url', + }); + + translateService = getMockTranslateService(); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), RouterTestingModule.withRoutes([]), BrowserAnimationsModule], + declarations: [FileDescriptionComponent], + providers: [ + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: HALEndpointService, useValue: halService } + ] + }).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..68dd27f4dab --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/file-description/file-description.component.ts @@ -0,0 +1,43 @@ +import { Component, Input } from '@angular/core'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; + +const allowedPreviewFormats = ['text/plain', 'text/html', 'application/zip']; +@Component({ + selector: 'ds-file-description', + templateUrl: './file-description.component.html', + styleUrls: ['./file-description.component.scss'], +}) +export class FileDescriptionComponent { + @Input() + fileInput: MetadataBitstream; + + constructor(protected halService: HALEndpointService) { } + + public downloadFiles() { + console.log('${this.fileInput.href}', `${this.fileInput.href}`); + window.location.href = this.halService.getRootHref().replace('/server/api', '') + `${this.fileInput.href}`; + } + + public isTxt() { + return this.fileInput?.format === 'text/plain'; + } + + /** + * Show scrollbar in the `.txt` preview, but it should be hidden in the other formats. + */ + public dynamicOverflow() { + return this.isTxt() ? 'overflow: scroll' : 'overflow: hidden'; + } + + /** + * Supported Preview formats are: `text/plain`, `text/html`, `application/zip` + */ + public couldPreview() { + if (this.fileInput.canPreview === false) { + return false; + } + + return allowedPreviewFormats.includes(this.fileInput.format); + } +} 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..2b82e44760f --- /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,25 @@ +
  • + + + + {{ node.name }} + + + + + + {{ node.name }} + {{ node.size }} + + +
      + + +
    +
  • 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..0585ae22f29 --- /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; +} 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..1ffa042a5a6 --- /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,57 @@ +import { ComponentFixture, TestBed, waitForAsync } 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', () => { + waitForAsync(() => { + 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..8404f491b4f --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.html @@ -0,0 +1,3 @@ +
    + +
    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..fb6cb457842 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.scss @@ -0,0 +1,108 @@ +.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; + } +} 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..ac8b5df4dd4 --- /dev/null +++ b/src/app/item-page/simple/field-components/preview-section/preview-section.component.ts @@ -0,0 +1,28 @@ +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([] 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 fc4aaeffc17..8d2c9048344 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -11,6 +11,28 @@ +
     {{'item.page.files.head' | translate}}
    + + diff --git a/src/app/item-page/simple/item-page.component.scss b/src/app/item-page/simple/item-page.component.scss index 24ab7394c3e..5e419c675d0 100644 --- a/src/app/item-page/simple/item-page.component.scss +++ b/src/app/item-page/simple/item-page.component.scss @@ -28,3 +28,58 @@ margin-top: -16px; padding-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 9b0e87939df..8c0bb70cdda 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'; @@ -22,6 +22,15 @@ import { 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'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -40,6 +49,12 @@ describe('ItemPageComponent', () => { let comp: ItemPageComponent; let fixture: ComponentFixture; let authService: AuthService; + let translateService: TranslateService; + let registryService: RegistryService; + let halService: HALEndpointService; + const authorizationService = jasmine.createSpyObj('authorizationService', [ + 'isAuthorized', + ]); let authorizationDataService: AuthorizationDataService; const mockMetadataService = { @@ -52,15 +67,34 @@ describe('ItemPageComponent', () => { data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }) }); + const mockMetadataBitstreamDataService = { + searchByHandleParams: () => observableOf({}) // Returns a mock Observable + }; + beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), setRedirectUrl: {} }); + + translateService = getMockTranslateService(); authorizationDataService = jasmine.createSpyObj('authorizationDataService', { isAuthorized: observableOf(false), }); + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + + halService = jasmine.createSpyObj('authService', { + getRootHref: 'root url', + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -75,7 +109,15 @@ describe('ItemPageComponent', () => { { provide: MetadataService, useValue: mockMetadataService }, { 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, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: HALEndpointService, useValue: halService } ], schemas: [NO_ERRORS_SCHEMA] @@ -85,6 +127,7 @@ describe('ItemPageComponent', () => { })); beforeEach(waitForAsync(() => { + registryService = TestBed.inject(RegistryService); fixture = TestBed.createComponent(ItemPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index a4f9eb3766c..f9768ea75a0 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,20 +1,25 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; - -import { Observable} from 'rxjs'; import { map, take } from 'rxjs/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { fadeInOut } from '../../shared/animations/fade'; -import { getAllSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { + getAllSucceededRemoteDataPayload, + getAllSucceededRemoteListPayload, +} 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 { 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 { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model'; +import { Observable} from 'rxjs'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; /** * This component renders a simple item page. @@ -49,6 +54,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 = false; + /** + * command for the download command feature + */ + command: string; /** * Whether the current user is an admin or not @@ -65,14 +86,31 @@ 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; + itemUrl: string; + canShowCurlDownload = false; + constructor( protected route: ActivatedRoute, private router: Router, private items: ItemDataService, private authService: AuthService, - private authorizationService: AuthorizationDataService + private authorizationService: AuthorizationDataService, + protected registryService: RegistryService, + protected halService: HALEndpointService, ) { } @@ -90,6 +128,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() { @@ -103,6 +178,7 @@ export class ItemPageComponent implements OnInit { take(1), getAllSucceededRemoteDataPayload()) .subscribe((item: Item) => { + this.itemHandle = item.handle; isWithdrawn = item.isWithdrawn; isReplaced = item.metadata['dc.relation.isreplacedby']?.[0]?.value; }); @@ -126,4 +202,33 @@ export class ItemPageComponent implements OnInit { } }); } + + setCommandline() { + this.isCommandLineVisible = !this.isCommandLineVisible; + } + + generateCurlCommand() { + const fileNames = this.listOfFiles.map((file: MetadataBitstream) => { + // Show `Download All Files` only if there are more files. + if (file.canPreview && this.listOfFiles.length > 1) { + this.canDownloadAllFiles = file.canPreview; + } + + if (file.canPreview) { + this.canShowCurlDownload = true; + } + + return file.name; + }); + + this.command = `curl --remote-name-all ` + this.halService.getRootHref() + `/core/bitstreams/handle/${ + this.itemHandle + }/{${fileNames.join(',')}}`; + } + + downloadFiles() { + window.location.href = this.halService.getRootHref() + `/core/bitstreams/allzip?handleId=${this.itemHandle}`; + } + + } diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index ea866af5e01..c6f00dafe22 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -95,9 +95,9 @@ {{"item.page.link.full" | translate}} - - + + + + 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 ae2cde511d6..17f3675f944 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 @@ -101,9 +101,9 @@ {{"item.page.link.full" | translate}} - - + + + + diff --git a/src/app/submission/sections/clarin-license-resource/ufal-theme.css b/src/app/submission/sections/clarin-license-resource/ufal-theme.css index 8bb5d65fd7e..b340bee4658 100644 --- a/src/app/submission/sections/clarin-license-resource/ufal-theme.css +++ b/src/app/submission/sections/clarin-license-resource/ufal-theme.css @@ -1427,17 +1427,17 @@ div.modal-scrollbar { } .treeview .foldername:before { - font-family: FontAwesome; - content: '\f07b'; - margin-right: 5px; - color: #A0A0A0; + font-family: FontAwesome; + content: '\f07b'; + margin-right: 5px; + color: #A0A0A0; } .treeview .filename:before { - font-family: FontAwesome; - content: '\f016'; - margin-right: 5px; - color: #A0A0A0; + font-family: FontAwesome; + content: '\f016'; + margin-right: 5px; + color: #A0A0A0; } .filebutton { diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 88be6f7737a..049b7911501 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -5126,6 +5126,43 @@ "item.view.box.no-files.message": "Tento záznam neobsahuje soubory.", +// "item.file.description.not.supported.video": "Your browser does not support the video tag.", + "item.file.description.not.supported.video": "Váš prohlížeč nepodporuje videa.", + +// "item.file.description.name": "Name", + "item.file.description.name": "Název", + +// "item.file.description.size": "Size", + "item.file.description.size": "Velikost", + +// "item.file.description.format": "Format", + "item.file.description.format": "Formát", + +// "item.file.description.description": "Description", + "item.file.description.description": "Popis", + +// "item.file.description.checksum": "MD5", + "item.file.description.checksum": "MD5", + +// "item.file.description.download.file": "Download file", + "item.file.description.download.file": "Stáhnout soubor", + +// "item.file.description.preview": "Preview", + "item.file.description.preview": "Náhled", + +// "item.file.description.file.preview": "File Preview", + "item.file.description.file.preview": "Náhled souboru", + + // "item.page.files.head": "Files in this item", + "item.page.files.head": "Soubory tohoto záznamu", + + // "item.page.download.button.command.line": "Download instructions for command line", + "item.page.download.button.command.line": "Instrukce pro stažení z příkazové řádky", + + // "item.page.download.button.all.files.zip": "Download all files in item", + "item.page.download.button.all.files.zip": "Stáhnout všechny soubory záznamu", + + // "item.select.confirm" : "Confirm selected" "item.select.confirm" : "Potvrdit vybrané", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index f3011292707..63392b829c3 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2776,6 +2776,31 @@ "item.view.box.no-files.message": "This item contains no files.", + "item.file.description.not.supported.video": "Your browser does not support the video tag.", + + "item.file.description.name": "Name", + + "item.file.description.size": "Size", + + "item.file.description.format": "Format", + + "item.file.description.description": "Description", + + "item.file.description.checksum": "MD5", + + "item.file.description.download.file": "Download file", + + "item.file.description.preview": "Preview", + + "item.file.description.file.preview": "File Preview", + + "item.page.files.head": "Files in this item", + + "item.page.download.button.command.line": "Download instructions for command line", + + "item.page.download.button.all.files.zip": "Download all files in item", + + "item.select.confirm": "Confirm selected", "item.select.empty": "No items to show", diff --git a/src/assets/images/application-x-gzip.png b/src/assets/images/application-x-gzip.png new file mode 100644 index 0000000000000000000000000000000000000000..be9a74a89d43e60ec244cfe245affd468f3ded8d GIT binary patch literal 11414 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 Ild*{SUt*u^cmMzZ literal 0 HcmV?d00001