diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index 73190c57caf..83c2fba7814 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -26,6 +26,7 @@ import { EditCMSMetadataMenuProvider } from './shared/menu/providers/edit-cms-me import { EditItemMenuProvider } from './shared/menu/providers/edit-item-details.menu'; import { EditUserAgreementMenuProvider } from './shared/menu/providers/edit-user-agreement.menu'; import { ExportMenuProvider } from './shared/menu/providers/export.menu'; +import { ExportItemMenuProvider } from './shared/menu/providers/export-item.menu'; import { HealthMenuProvider } from './shared/menu/providers/health.menu'; import { ImportMenuProvider } from './shared/menu/providers/import.menu'; import { ClaimMenuProvider } from './shared/menu/providers/item-claim.menu'; @@ -50,6 +51,7 @@ import { WorkflowMenuProvider } from './shared/menu/providers/workflow.menu'; * - `MenuID.PUBLIC`: Defines menus accessible by the public in the navigation bar. * - `MenuID.ADMIN`: Defines menus for administrative users in the sidebar. * - `MenuID.DSO_EDIT`: Defines dynamic menu options for DSpace Objects that will be present on the DSpace Object's page. + * - `MenuID.DSO_PUBLIC`: Defines dynamic menu options for DSpace Objects available to unauthenticated users. * * To add more menu sections to a menu (public navbar, admin sidebar or the dso edit menus), * a new provider can be added to the list with the corresponding menu ID. @@ -118,4 +120,9 @@ export const MENUS = buildMenuStructure({ ), ]), ], + [MenuID.DSO_PUBLIC]: [ + ExportItemMenuProvider.onRoute( + MenuRoute.ITEM_PAGE, + ), + ], }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 44aafc2690b..e0aca30b026 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -154,6 +154,55 @@ export class CollectionDataService extends ComColDataService { ); } + /** + * Get all collections the user is admin + * + * @param query limit the returned collection to those with metadata values matching the query terms. + * @param options The [[FindListOptions]] object + * @param reRequestOnStale Whether or not the request should automatically be re-requested after + * the response becomes stale + * @param linksToFollow The array of [[FollowLinkConfig]] + * @return Observable>> + * collection list + */ + getAdministeredCollection(query: string, options: FindListOptions = {}, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAdministered'; + options = Object.assign({}, options, { + searchParams: [new RequestParam('query', query)], + }); + + return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + } + + /** + * Get all collections the user is admin selected by entityType + * + * @param query limit the returned collection to those with metadata values matching the query terms. + * @param entityType mandatory, the label of the entity type field the collection must have. + * @param options The [[FindListOptions]] object + * @param reRequestOnStale Whether or not the request should automatically be re-requested after + * the response becomes stale + * @param linksToFollow The array of [[FollowLinkConfig]] + * @return Observable>> + * collection list + */ + getAdministeredCollectionByEntityType(query: string, entityType: string, options: FindListOptions = {}, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + + const searchHref = 'findAdminAuthorizedByEntityType'; + options = Object.assign({}, options, { + searchParams: [ + new RequestParam('query', query), + new RequestParam('entityType', entityType), + ], + }); + + return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + } + /** * Get all collections the user is authorized to submit to * @@ -254,8 +303,8 @@ export class CollectionDataService extends ComColDataService { options.elementsPerPage = 1; return this.searchBy(searchHref, options).pipe( - getFirstCompletedRemoteData(), - map((collections: RemoteData>) => collections?.payload?.totalElements > 0), + getAllCompletedRemoteData(), + map((collections: RemoteData>) => collections.payload.totalElements > 0), ); } diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index e87f3d7ce37..f6af9947801 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -294,4 +294,12 @@ export class ProcessDataService extends IdentifiableDataService impleme ), ); } + + /** + * Get process' details + * @param processId The ID of the process + */ + getProcess(processId: string): Observable> { + return this.findById(processId, false); + } } diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 883380be9f0..9d12898545c 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -34,6 +34,8 @@ export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; export const BATCH_IMPORT_SCRIPT_NAME = 'import'; export const BATCH_EXPORT_SCRIPT_NAME = 'export'; export const DSPACE_OBJECT_DELETION_SCRIPT_NAME = 'object-deletion'; +export const ITEM_EXPORT_SCRIPT_NAME = 'item-export'; +export const BULK_ITEM_EXPORT_SCRIPT_NAME = 'bulk-item-export'; @Injectable({ providedIn: 'root' }) @dataService(SCRIPT) diff --git a/src/app/core/itemexportformat/item-export-format.service.spec.ts b/src/app/core/itemexportformat/item-export-format.service.spec.ts new file mode 100644 index 00000000000..b26ddbdf963 --- /dev/null +++ b/src/app/core/itemexportformat/item-export-format.service.spec.ts @@ -0,0 +1,191 @@ +import { EventEmitter } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { RequestParam } from '../cache/models/request-param.model'; +import { + SortDirection, + SortOptions, +} from '../cache/models/sort-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { + BULK_ITEM_EXPORT_SCRIPT_NAME, + ITEM_EXPORT_SCRIPT_NAME, + ScriptDataService, +} from '../data/processes/script-data.service'; +import { NotificationsService } from '../notification-system/notifications.service'; +import { Process } from '../processes/process.model'; +import { ProcessParameter } from '../processes/process-parameter.model'; +import { PaginatedSearchOptions } from '../shared/search/models/paginated-search-options.model'; +import { SearchFilter } from '../shared/search/models/search-filter.model'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { createPaginatedList } from '../testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '../utilities/remote-data.utils'; +import { + ItemExportFormatMolteplicity, + ItemExportFormatService, +} from './item-export-format.service'; +import { ItemExportFormat } from './model/item-export-format.model'; +import createSpyObj = jasmine.createSpyObj; + +const ItemExportFormatsMap = { + Publication: [ + Object.assign(new ItemExportFormat(), { id: 'publication-xml', entityType: 'Publication', molteplicity: 'MULTIPLE' }), + Object.assign(new ItemExportFormat(), { id: 'publication-csv', entityType: 'Publication', molteplicity: 'MULTIPLE' }), + ], + Project: [ + Object.assign(new ItemExportFormat(), { id: 'project-xml', entityType: 'Project', molteplicity: 'MULTIPLE' }), + ], +}; + +describe('ItemExportFormatService', () => { + + let service: ItemExportFormatService; + const notificationsService: NotificationsService = new NotificationsServiceStub() as any; + const translateService: TranslateService = { + get: () => of('test-message'), + onLangChange: new EventEmitter(), + onTranslationChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter(), + } as any; + let scriptDataService: ScriptDataService; + const TheProcess = Object.assign(new Process(), { + processId: 1234, + resourceSelfLinks: ['process/1234'], + }); + + beforeEach(() => { + scriptDataService = createSpyObj('scriptDataService', ['invoke']); + service = new ItemExportFormatService( + null, + null, + null, + null, + notificationsService, + null, + null, + translateService, + scriptDataService); + }); + + describe('byEntityTypeAndMolteplicity', () => { + + beforeEach(() => { + const searchResult: any = [...ItemExportFormatsMap.Publication, ...ItemExportFormatsMap.Project]; + const paginatedList: PaginatedList = createPaginatedList(searchResult); + spyOn((service as any).searchData, 'searchBy').and.returnValue(createSuccessfulRemoteDataObject$(paginatedList)); + }); + + it('should configure and call dataService.searchBy and map results by entityType', (done) => { + const entityTypeId = 'Publication'; + const molteplicity = ItemExportFormatMolteplicity.MULTIPLE; + + const searchParams = [ + new RequestParam('molteplicity', molteplicity), + new RequestParam('entityTypeId', entityTypeId), + ]; + + service.byEntityTypeAndMolteplicity(entityTypeId, molteplicity).subscribe((result) => { + expect((service as any).searchData.searchBy).toHaveBeenCalledWith('byEntityTypeAndMolteplicity', { searchParams, elementsPerPage: 100 }); + expect(result).toEqual(ItemExportFormatsMap); + done(); + }); + + expect(service).toBeTruthy(); + }); + + }); + + describe('doExport', () => { + + beforeEach(() => { + (scriptDataService as any).invoke.and.returnValue(createSuccessfulRemoteDataObject$(TheProcess)); + }); + + it('should invoke a configured single item export', (done) => { + const format = Object.assign(new ItemExportFormat(), { id: 'publication-xml' }); + const uuid = 'itemUUID'; + const expectedParameters = [ + Object.assign(new ProcessParameter(), { name: '-i', value: 'itemUUID' }), + Object.assign(new ProcessParameter(), { name: '-f', value: 'publication-xml' }), + ]; + service.doExport(uuid, format).subscribe((result) => { + expect(result).toEqual(1234); + expect((service as any).scriptDataService.invoke).toHaveBeenCalledWith(ITEM_EXPORT_SCRIPT_NAME, expectedParameters, []); + done(); + }); + + }); + + }); + + describe('doExportMulti', () => { + + beforeEach(() => { + (scriptDataService as any).invoke.and.returnValue(createSuccessfulRemoteDataObject$(TheProcess)); + }); + + it('should invoke a configured bulk item export', (done) => { + const entityType = 'Publication'; + const format = Object.assign(new ItemExportFormat(), { id: 'publication-xml' }); + const searchOptions = new PaginatedSearchOptions({ + query: 'queryX', + filters: [ + new SearchFilter('f.name', ['nameX', 'nameY']), + new SearchFilter('f.type', ['typeX']), + new SearchFilter('other.name', ['nameY']), + ], + fixedFilter: 'scope=scopeX', + configuration: 'configurationX', + sort: new SortOptions('fieldX', SortDirection.ASC), + }); + + const expectedParameters = [ + Object.assign(new ProcessParameter(), { name: '-f', value: 'publication-xml' }), + Object.assign(new ProcessParameter(), { name: '-t', value: 'Publication' }), + Object.assign(new ProcessParameter(), { name: '-q', value: 'queryX' }), + Object.assign(new ProcessParameter(), { name: '-sf', value: 'name=nameX&name=nameY&type=typeX' }), + Object.assign(new ProcessParameter(), { name: '-s', value: 'scopeX' }), + Object.assign(new ProcessParameter(), { name: '-c', value: 'configurationX' }), + Object.assign(new ProcessParameter(), { name: '-so', value: 'fieldX,ASC' }), + ]; + + service.doExportMulti(entityType, format, searchOptions).subscribe((result) => { + expect(result).toEqual(1234); + expect((service as any).scriptDataService.invoke).toHaveBeenCalledWith(BULK_ITEM_EXPORT_SCRIPT_NAME, expectedParameters, []); + done(); + }); + + }); + + it('should invoke a configured bulk item export with a list', (done) => { + const entityType = 'Publication'; + const format = Object.assign(new ItemExportFormat(), { id: 'publication-xml' }); + const searchOptions = new PaginatedSearchOptions({ + query: 'queryX', + filters: [ + new SearchFilter('f.name', ['nameX']), + new SearchFilter('f.type', ['typeX']), + new SearchFilter('other.name', ['nameY']), + ], + fixedFilter: 'scope=scopeX', + configuration: 'configurationX', + sort: new SortOptions('fieldX', SortDirection.ASC), + }); + + const expectedParameters = [ + Object.assign(new ProcessParameter(), { name: '-f', value: 'publication-xml' }), + Object.assign(new ProcessParameter(), { name: '-si', value: 'item1;item2' }), + ]; + + service.doExportMulti(entityType, format, searchOptions, ['item1', 'item2']).subscribe((result) => { + expect(result).toEqual(1234); + expect((service as any).scriptDataService.invoke).toHaveBeenCalledWith(BULK_ITEM_EXPORT_SCRIPT_NAME, expectedParameters, []); + done(); + }); + + }); + + }); + +}); diff --git a/src/app/core/itemexportformat/item-export-format.service.ts b/src/app/core/itemexportformat/item-export-format.service.ts new file mode 100644 index 00000000000..647c836c819 --- /dev/null +++ b/src/app/core/itemexportformat/item-export-format.service.ts @@ -0,0 +1,258 @@ +import { Injectable } from '@angular/core'; +import { + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import findIndex from 'lodash/findIndex'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { DSONameService } from '../breadcrumbs/dso-name.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { SearchDataImpl } from '../data/base/search-data'; +import { ItemDataService } from '../data/item-data.service'; +import { PaginatedList } from '../data/paginated-list.model'; +import { + BULK_ITEM_EXPORT_SCRIPT_NAME, + ITEM_EXPORT_SCRIPT_NAME, + ScriptDataService, +} from '../data/processes/script-data.service'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { NotificationsService } from '../notification-system/notifications.service'; +import { Process } from '../processes/process.model'; +import { ProcessParameter } from '../processes/process-parameter.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + getAllCompletedRemoteData, + getFirstCompletedRemoteData, +} from '../shared/operators'; +import { PaginatedSearchOptions } from '../shared/search/models/paginated-search-options.model'; +import { SearchOptions } from '../shared/search/models/search-options.model'; +import { + ItemExportFormat, + ItemExportFormatMap, +} from './model/item-export-format.model'; + +/** + * Enum representing the multiplicity of an item export operation. + * SINGLE is used for exporting individual items, MULTIPLE for bulk exports. + */ +export enum ItemExportFormatMolteplicity { + SINGLE = 'SINGLE', + MULTIPLE = 'MULTIPLE' +} + +/** + * A service that provides methods to make REST requests with item export format endpoint. + */ +@Injectable({ providedIn: 'root' }) +export class ItemExportFormatService extends IdentifiableDataService { + + private searchData: SearchDataImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected dsoNameService: DSONameService, + protected itemService: ItemDataService, + protected translate: TranslateService, + protected scriptDataService: ScriptDataService) { + + super('itemexportformats', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Get all item export formats for the requested entityType and compatible with the given molteplicity + * + * @param entityTypeId The entityType id (null means every entity types) + * @param molteplicity The requested molteplicity + * @return Observable<{ [entityType: string]: ItemExportFormat[]}> + * dictionary which map for the requested entityTypesId all the allowed export formats + */ + byEntityTypeAndMolteplicity(entityTypeId: string, molteplicity: ItemExportFormatMolteplicity): Observable { + const searchHref = 'byEntityTypeAndMolteplicity'; + + const searchParams = []; + if (molteplicity) { + searchParams.push(new RequestParam('molteplicity', molteplicity)); + } + if (entityTypeId) { + searchParams.push(new RequestParam('entityTypeId', entityTypeId)); + } + + return this.searchData.searchBy(searchHref, { searchParams, elementsPerPage: 100 }).pipe( + getAllCompletedRemoteData(), + map((itemExportFormatsRD: RemoteData>) => { + const formatMap = {}; + const sharedFormat = []; + itemExportFormatsRD.payload.page.forEach((format) => { + if (isEmpty(format.entityType)) { + if (findIndex(sharedFormat, (entry) => entry.id === format.id) === -1) { + sharedFormat.push(format); + } + } else { + formatMap[format.entityType] = formatMap[format.entityType] ? [...formatMap[format.entityType], format] : [format]; + } + }); + + Object.keys(formatMap).forEach((itemType) => { + formatMap[itemType] = [...formatMap[itemType], ...sharedFormat]; + }); + return formatMap; + }), + ); + } + + /** + * Starts item-export script. + * @param uuid + * @param format + * + * @return an Observable containing the processNumber if the script starts successfully, or null in case of errors + */ + public doExport(uuid: string, format: ItemExportFormat): Observable { + + let parameterValues = []; + parameterValues = this.uuidParameter(uuid, parameterValues); + parameterValues = this.formatParameter(format, parameterValues); + + return this.launchScript(ITEM_EXPORT_SCRIPT_NAME, parameterValues); + } + + /** + * * Starts a bulk-item-export script. + * + * @param entityType The requested entityType + * @param format The requested export format + * @param searchOptions the state of the search to model into a bulk-item-export process + * @param itemList If not empty contains the list of item to export only + * + * @return an Observable containing the processNumber if the script starts successfully, or null in case of errors + */ + public doExportMulti(entityType: string, format: ItemExportFormat, searchOptions: SearchOptions, itemList: string[] = []): Observable { + + let parameterValues = []; + parameterValues = this.formatParameter(format, parameterValues); + if (isNotEmpty(itemList)) { + parameterValues = this.listUUIDParameter(itemList.join(';'), parameterValues); + } else { + parameterValues = this.entityTypeParameter(entityType, parameterValues); + parameterValues = this.queryParameter(searchOptions, parameterValues); + parameterValues = this.filtersParameter(searchOptions, parameterValues); + parameterValues = this.scopeParameter(searchOptions, parameterValues); + parameterValues = this.configurationParameter(searchOptions, parameterValues); + parameterValues = this.sortParameter(searchOptions, parameterValues); + } + + return this.launchScript(BULK_ITEM_EXPORT_SCRIPT_NAME, parameterValues); + } + + /** + * Launch a script via the ScriptDataService and return the process ID on success. + * Shows an error notification on failure. + * @param scriptName - The name of the script to invoke + * @param parameterValues - The parameters to pass to the script + * @returns Observable emitting the process ID on success, or null on failure + */ + private launchScript(scriptName: string, parameterValues: ProcessParameter[]): Observable { + return this.scriptDataService.invoke(scriptName, parameterValues, []) + .pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.isSuccess) { + const payload: any = rd.payload; + return payload.processId; + } else { + const title = this.translate.get('process.new.notification.error.title'); + const content = this.translate.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + return null; + } + })); + } + + private uuidParameter(uuid: string, parameterValues: ProcessParameter[]): ProcessParameter[] { + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-i', value: uuid })]; + } + + private entityTypeParameter(entityType: string, parameterValues: ProcessParameter[]): ProcessParameter[] { + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-t', value: entityType })]; + } + + private formatParameter(format: ItemExportFormat, parameterValues: ProcessParameter[]): ProcessParameter[] { + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-f', value: format.id })]; + } + + private listUUIDParameter(list: string, parameterValues: ProcessParameter[]): ProcessParameter[] { + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-si', value: list })]; + } + + private queryParameter(searchOptions: SearchOptions, parameterValues: ProcessParameter[]): ProcessParameter[] { + if (searchOptions.query) { + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-q', value: searchOptions.query })]; + } + return parameterValues; + } + + private filtersParameter(searchOptions: SearchOptions, parameterValues: ProcessParameter[]): ProcessParameter[] { + if (searchOptions.filters && searchOptions.filters.length > 0) { + const value = searchOptions.filters + .filter((searchFilter) => searchFilter.key.includes('f.')) + .map((searchFilter) => { + const key = searchFilter.key.replace('f.', ''); + return searchFilter.values.map((filterValue) => { + const baseValue = `${key}=${filterValue}`; + return searchFilter.operator ? `${baseValue},${searchFilter.operator}` : baseValue; + }).join('&'); + }) + .join('&'); + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-sf', value })]; + } + return parameterValues; + } + + private scopeParameter(searchOptions: SearchOptions, parameterValues: ProcessParameter[]): ProcessParameter[] { + if (searchOptions.fixedFilter) { + const fixedFilter = searchOptions.fixedFilter.split('='); + if (fixedFilter.length === 2 && fixedFilter[0] === 'scope') { + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-s', value: fixedFilter[1] })]; + } + } + if (searchOptions.scope) { + return [...parameterValues, Object.assign(new ProcessParameter(), { name: '-s', value: searchOptions.scope })]; + } + return parameterValues; + } + + private configurationParameter(searchOptions: SearchOptions, parameterValues: ProcessParameter[]): ProcessParameter[] { + if (searchOptions.configuration) { + return [...parameterValues, Object.assign(new ProcessParameter(), { + name: '-c', + value: searchOptions.configuration, + })]; + } + return parameterValues; + } + + private sortParameter(searchOptions: SearchOptions, parameterValues: ProcessParameter[]): ProcessParameter[] { + if (searchOptions instanceof PaginatedSearchOptions) { + return [...parameterValues, Object.assign(new ProcessParameter(), { + name: '-so', + value: searchOptions.sort.field + ',' + searchOptions.sort.direction, + })]; + } + return parameterValues; + } + +} diff --git a/src/app/core/itemexportformat/model/item-export-format.model.ts b/src/app/core/itemexportformat/model/item-export-format.model.ts new file mode 100644 index 00000000000..aa528a24d79 --- /dev/null +++ b/src/app/core/itemexportformat/model/item-export-format.model.ts @@ -0,0 +1,64 @@ +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ITEM_EXPORT_FORMAT } from './item-export-format.resource-type'; + +export interface ItemExportFormatMap { + [entityType: string]: ItemExportFormat[] +} + +/** + * Class the represents an Item Export Format. + */ +@typedObject +export class ItemExportFormat extends CacheableObject { + + static type = ITEM_EXPORT_FORMAT; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Item Export Format. + */ + @autoserialize + id: string; + + /** + * The mimeType of this Item Export Format. + */ + @autoserialize + mimeType: string; + + /** + * The entityType of this Item Export Format. + */ + @autoserialize + entityType: string; + + /** + * The molteplicity of this Item Export Format. + */ + @autoserialize + molteplicity: string; + + /** + * The {@link HALLink}s for this Researcher Profile + */ + @deserialize + _links: { + self: HALLink + }; + +} diff --git a/src/app/core/itemexportformat/model/item-export-format.resource-type.ts b/src/app/core/itemexportformat/model/item-export-format.resource-type.ts new file mode 100644 index 00000000000..9f94bf5fddd --- /dev/null +++ b/src/app/core/itemexportformat/model/item-export-format.resource-type.ts @@ -0,0 +1,6 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for ItemExportFormat + */ +export const ITEM_EXPORT_FORMAT = new ResourceType('itemexportformat'); diff --git a/src/app/core/notification-system/models/process-notification.model.ts b/src/app/core/notification-system/models/process-notification.model.ts new file mode 100644 index 00000000000..fc114e8ba17 --- /dev/null +++ b/src/app/core/notification-system/models/process-notification.model.ts @@ -0,0 +1,77 @@ +import { isEmpty } from '@dspace/shared/utils/empty.util'; +import { Observable } from 'rxjs'; + +import { INotification } from './notification.model'; +import { + INotificationOptions, + NotificationOptions, +} from './notification-options.model'; +import { NotificationType } from './notification-type'; + +export interface IProcessNotification extends INotification { + processId: string; + checkTime: number; +} + +export class ProcessNotification implements IProcessNotification { + + /** + * Id of the notification. + */ + public id: string; + + /** + * Type of the notification. + */ + public type: NotificationType; + + /** + * Title of the notification. + */ + public title: Observable | string; + + /** + * Content of the notification. + */ + public content: Observable | string; + + /** + * Different configurations of the notification. + */ + public options: INotificationOptions; + + /** + * If title is html for or not. + */ + public html: boolean; + + /** + * Process ID this notification is emited for. + */ + public processId: string; + + /** + * Time interval that the notification will be rechecked. + */ + public checkTime: number; + + constructor(id: string, + type: NotificationType, + processId: string, + checkTime: number, + title?: Observable | string, + options?: NotificationOptions, + html?: boolean) { + this.id = id; + this.type = type; + this.title = title; + if (isEmpty(options)) { + options = Object.assign( new NotificationOptions(), { clickToClose: true }); + } + this.options = options; + this.html = html; + this.processId = processId; + this.checkTime = checkTime; + } + +} diff --git a/src/app/core/notification-system/notifications.service.ts b/src/app/core/notification-system/notifications.service.ts index b89762ab032..eb4a18f5a4d 100644 --- a/src/app/core/notification-system/notifications.service.ts +++ b/src/app/core/notification-system/notifications.service.ts @@ -1,4 +1,8 @@ import { Injectable } from '@angular/core'; +import { + IProcessNotification, + ProcessNotification, +} from '@dspace/core/notification-system/models/process-notification.model'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import uniqueId from 'lodash/uniqueId'; @@ -70,6 +74,17 @@ export class NotificationsService { return notification; } + process(processId: string, + checkTime: number, + title: any = of(''), + options: NotificationOptions = this.getDefaultOptions(), + html: boolean = false): IProcessNotification { + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new ProcessNotification(uniqueId(), NotificationType.Info, processId, checkTime, title, notificationOptions, html); + this.add(notification); + return notification; + } + notificationWithAnchor(notificationType: NotificationType, options: NotificationOptions, href: string, diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 2b83615c0c1..df623994b0f 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -36,6 +36,7 @@ import { import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; import { Feedback } from './feedback/models/feedback.model'; +import { ItemExportFormat } from './itemexportformat/model/item-export-format.model'; import { MetadataField } from './metadata/metadata-field.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model'; @@ -232,4 +233,5 @@ export const models = CorrectionType, SupervisionOrder, SubmissionCustomUrl, + ItemExportFormat, ]; diff --git a/src/app/core/testing/notifications-service.stub.ts b/src/app/core/testing/notifications-service.stub.ts index a40459d1a3d..6c486b5b26f 100644 --- a/src/app/core/testing/notifications-service.stub.ts +++ b/src/app/core/testing/notifications-service.stub.ts @@ -8,6 +8,7 @@ export class NotificationsServiceStub { warning = jasmine.createSpy('warning'); remove = jasmine.createSpy('remove'); removeAll = jasmine.createSpy('removeAll'); + process = jasmine.createSpy('process'); private getDefaultOptions(): NotificationOptions { return new NotificationOptions(); diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index e26052285fc..e0f7bd12c50 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -4,6 +4,7 @@
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts index 0a0bd4cd53a..42a6499bd7c 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -9,6 +9,7 @@ import { ThemedItemPageTitleFieldComponent } from '../../../../item-page/simple/ import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -22,6 +23,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail imports: [ AsyncPipe, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, RelatedItemsComponent, diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 6a2018d49cc..b5e00603a3c 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -4,6 +4,7 @@
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts index 540a1e6db69..13c3f9ceeb1 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -9,6 +9,7 @@ import { ThemedItemPageTitleFieldComponent } from '../../../../item-page/simple/ import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -22,6 +23,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail imports: [ AsyncPipe, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, RelatedItemsComponent, diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index 5452ddd9c40..2bb318f5cc4 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -4,6 +4,7 @@
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index c6116a919d5..24a9ba19824 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -54,6 +54,7 @@ import { ThemedMetadataRepresentationListComponent } from '../../../../item-page import { TabbedRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; import { SearchService } from '../../../../shared/search/search.service'; @@ -144,6 +145,7 @@ describe('JournalComponent', () => { RelatedItemsComponent, TabbedRelatedEntitiesSearchComponent, ThemedMetadataRepresentationListComponent, + DsoPublicMenuComponent, ], }, add: { changeDetection: ChangeDetectionStrategy.Default }, diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index 3824c3178da..0a8d60f98d6 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -10,6 +10,7 @@ import { ItemComponent } from '../../../../item-page/simple/item-types/shared/it import { TabbedRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -23,6 +24,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail imports: [ AsyncPipe, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, RelatedItemsComponent, diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index cd990c064c4..08bf6e77745 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -4,6 +4,7 @@
+
diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts index 67b88aedf51..bed98e3fb0b 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts @@ -12,6 +12,7 @@ import { AuthorityRelatedEntitiesSearchComponent } from '../../../../item-page/s import { TabbedRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -26,6 +27,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail AsyncPipe, AuthorityRelatedEntitiesSearchComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, ItemPageImgFieldComponent, MetadataFieldWrapperComponent, diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index 1b08649235d..35340c0e91b 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -4,6 +4,7 @@
+
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 6b1c296d074..0351c5edc6e 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -12,6 +12,7 @@ import { AuthorityRelatedEntitiesSearchComponent } from '../../../../item-page/s import { TabbedRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -26,6 +27,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail AsyncPipe, AuthorityRelatedEntitiesSearchComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, ItemPageOrcidFieldComponent, MetadataFieldWrapperComponent, diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index 3fb2796059e..ac5c6fb6345 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -4,6 +4,7 @@
+
diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts index 8ce17eaf76a..7788f5bdded 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts @@ -10,6 +10,7 @@ import { ItemComponent } from '../../../../item-page/simple/item-types/shared/it import { ThemedMetadataRepresentationListComponent } from '../../../../item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; import { RelatedItemsComponent } from '../../../../item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -23,6 +24,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail imports: [ AsyncPipe, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, RelatedItemsComponent, diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index 43ecae9f170..10754400d6b 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -9,6 +9,7 @@
+
@if (!fromSubmissionObject) { 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 2b7a0e0a0b5..4afaaf51959 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 @@ -41,6 +41,7 @@ import { } from 'rxjs'; import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { getMockThemeService } from '../../shared/theme-support/test/theme-service.mock'; import { ThemeService } from '../../shared/theme-support/theme.service'; @@ -164,6 +165,7 @@ describe('FullItemPageComponent', () => { ThemedLoadingComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, ThemedItemAlertsComponent, CollectionsComponent, ThemedFullFileSectionComponent, 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 ace0f9a91f4..6ecd1bc33d3 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -39,6 +39,7 @@ import { import { fadeInOut } from '../../shared/animations/fade'; import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { ErrorComponent } from '../../shared/error/error.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { VarDirective } from '../../shared/utils/var.directive'; @@ -65,6 +66,7 @@ import { ThemedFullFileSectionComponent } from './field-components/file-section/ AsyncPipe, CollectionsComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, ErrorComponent, ItemVersionsComponent, ItemVersionsNoticeComponent, diff --git a/src/app/item-page/simple/item-types/dataset/dataset.component.html b/src/app/item-page/simple/item-types/dataset/dataset.component.html index a02b74d209d..58f04d6c373 100644 --- a/src/app/item-page/simple/item-types/dataset/dataset.component.html +++ b/src/app/item-page/simple/item-types/dataset/dataset.component.html @@ -15,6 +15,7 @@
+
diff --git a/src/app/item-page/simple/item-types/dataset/dataset.component.spec.ts b/src/app/item-page/simple/item-types/dataset/dataset.component.spec.ts index 6c7159eaf4c..920fd8b4d69 100644 --- a/src/app/item-page/simple/item-types/dataset/dataset.component.spec.ts +++ b/src/app/item-page/simple/item-types/dataset/dataset.component.spec.ts @@ -51,6 +51,7 @@ import { import { environment } from '../../../../../environments/environment.test'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; import { SearchService } from '../../../../shared/search/search.service'; @@ -137,7 +138,7 @@ describe('DatasetComponent', () => { }).overrideComponent(DatasetComponent, { add: { changeDetection: ChangeDetectionStrategy.Default }, remove: { - imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ExtendedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, GeospatialItemPageFieldComponent], + imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, DsoPublicMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ExtendedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, GeospatialItemPageFieldComponent], }, }); })); diff --git a/src/app/item-page/simple/item-types/dataset/dataset.component.ts b/src/app/item-page/simple/item-types/dataset/dataset.component.ts index ed156a22549..2b4dc6d61e7 100644 --- a/src/app/item-page/simple/item-types/dataset/dataset.component.ts +++ b/src/app/item-page/simple/item-types/dataset/dataset.component.ts @@ -5,6 +5,7 @@ import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { TranslatePipe } from '@ngx-translate/core'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -34,6 +35,7 @@ import { ItemComponent } from '../shared/item.component'; AsyncPipe, CollectionsComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, ExtendedFileSectionComponent, GenericItemPageFieldComponent, GeospatialItemPageFieldComponent, 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 f5769793132..0af09c3df19 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 @@ -15,6 +15,7 @@
+
diff --git a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts index 58e9e1d0fb7..15772fc9c2b 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts @@ -53,6 +53,7 @@ import { import { environment } from '../../../../../environments/environment.test'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; import { SearchService } from '../../../../shared/search/search.service'; @@ -145,7 +146,7 @@ describe('PublicationComponent', () => { }).overrideComponent(PublicationComponent, { add: { changeDetection: ChangeDetectionStrategy.Default }, remove: { - imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ThemedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, + imports: [ThemedResultsBackButtonComponent, MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, DsoPublicMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, ThemedFileSectionComponent, ItemPageDateFieldComponent, ThemedMetadataRepresentationListComponent, GenericItemPageFieldComponent, RelatedItemsComponent, ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, CollectionsComponent, ], }, }); diff --git a/src/app/item-page/simple/item-types/publication/publication.component.ts b/src/app/item-page/simple/item-types/publication/publication.component.ts index 1b73c6efc70..34a83ff69d2 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.ts @@ -9,6 +9,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { AttachmentSectionComponent } from '../../../../shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -43,6 +44,7 @@ import { ItemComponent } from '../shared/item.component'; AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, GeospatialItemPageFieldComponent, ItemPageAbstractFieldComponent, diff --git a/src/app/item-page/simple/item-types/shared/item.component.spec.ts b/src/app/item-page/simple/item-types/shared/item.component.spec.ts index 5052c2cc66b..f625b12f211 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.spec.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.spec.ts @@ -66,6 +66,7 @@ import { import { environment } from '../../../../../environments/environment'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; import { SearchService } from '../../../../shared/search/search.service'; @@ -204,6 +205,7 @@ export function getItemPageFieldsTest(mockItem: Item, component) { ThemedResultsBackButtonComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, RelatedItemsComponent, 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 7141a88a8e2..f03858e21fa 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 @@ -16,6 +16,7 @@
+
diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts index a4130faeca1..c47ae234c86 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts @@ -52,6 +52,7 @@ import { import { environment } from '../../../../../environments/environment.test'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; import { SearchService } from '../../../../shared/search/search.service'; @@ -152,6 +153,7 @@ describe('UntypedItemComponent', () => { MiradorViewerComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, ThemedMediaViewerComponent, diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 35920fd09bd..0dd6509955e 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -10,6 +10,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { AttachmentSectionComponent } from '../../../../shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; @@ -43,6 +44,7 @@ import { ItemComponent } from '../shared/item.component'; AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, GeospatialItemPageFieldComponent, ItemPageAbstractFieldComponent, diff --git a/src/app/notification-system/notifications-board/notifications-board.component.html b/src/app/notification-system/notifications-board/notifications-board.component.html index 8272ec69a62..513789e88a7 100644 --- a/src/app/notification-system/notifications-board/notifications-board.component.html +++ b/src/app/notification-system/notifications-board/notifications-board.component.html @@ -9,3 +9,12 @@ }
+ +
+ @for (procNotification of processNotifications; track procNotification; let ix = $index) { + + + } +
diff --git a/src/app/notification-system/notifications-board/notifications-board.component.spec.ts b/src/app/notification-system/notifications-board/notifications-board.component.spec.ts index 18a140f38c7..b761cf51966 100644 --- a/src/app/notification-system/notifications-board/notifications-board.component.spec.ts +++ b/src/app/notification-system/notifications-board/notifications-board.component.spec.ts @@ -1,4 +1,8 @@ -import { ChangeDetectorRef } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + Input, +} from '@angular/core'; import { ComponentFixture, fakeAsync, @@ -15,9 +19,13 @@ import { INotificationBoardOptions } from '@dspace/config/notifications-config.i import { Notification } from '@dspace/core/notification-system/models/notification.model'; import { NotificationOptions } from '@dspace/core/notification-system/models/notification-options.model'; import { NotificationType } from '@dspace/core/notification-system/models/notification-type'; +import { ProcessNotification } from '@dspace/core/notification-system/models/process-notification.model'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { NotificationsServiceStub } from '@dspace/core/testing/notifications-service.stub'; -import { provideMockStore } from '@ngrx/store/testing'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { cold } from 'jasmine-marbles'; import uniqueId from 'lodash/uniqueId'; @@ -26,14 +34,24 @@ import { getAccessibilitySettingsServiceStub } from '../../accessibility/accessi import { LiveRegionService } from '../../shared/live-region/live-region.service'; import { LiveRegionServiceStub } from '../../shared/live-region/live-region.service.stub'; import { NotificationComponent } from '../notification/notification.component'; +import { ProcessNotificationComponent } from '../process-notification/process-notification.component'; import { NotificationsBoardComponent } from './notifications-board.component'; export const bools = { f: false, t: true }; +@Component({ + selector: 'ds-process-notification', + template: '', +}) +class MockProcessNotificationComponent { + @Input() notification: any; +} + describe('NotificationsBoardComponent', () => { let comp: NotificationsBoardComponent; let fixture: ComponentFixture; let liveRegionService: LiveRegionServiceStub; + let store: MockStore; const mockStoreModuleConfig: any = { runtimeChecks: { @@ -69,12 +87,16 @@ describe('NotificationsBoardComponent', () => { { provide: AccessibilitySettingsService, useValue: getAccessibilitySettingsServiceStub() }, ChangeDetectorRef, ], + }).overrideComponent(NotificationsBoardComponent, { + remove: { imports: [ProcessNotificationComponent] }, + add: { imports: [MockProcessNotificationComponent] }, }).compileComponents(); // compile template and css })); beforeEach(() => { fixture = TestBed.createComponent(NotificationsBoardComponent); comp = fixture.componentInstance; + store = TestBed.inject(MockStore); comp.options = { rtl: false, position: ['top', 'right'], @@ -166,5 +188,97 @@ describe('NotificationsBoardComponent', () => { })); }); + describe('addProccess', () => { + it('should add a process notification to the processNotifications array', () => { + const processNotification = new ProcessNotification( + 'proc-1', NotificationType.Info, 'process-123', 5000, 'Process running', + ); + + comp.addProccess(processNotification); + + expect(comp.processNotifications.length).toBe(1); + expect(comp.processNotifications[0].processId).toBe('process-123'); + }); + + it('should not add process notifications to the regular notifications array', () => { + const initialCount = comp.notifications.length; + const processNotification = new ProcessNotification( + 'proc-2', NotificationType.Info, 'process-456', 5000, 'Another process', + ); + + comp.addProccess(processNotification); + + expect(comp.notifications.length).toBe(initialCount); + }); + }); + + describe('process notifications via store', () => { + it('should route a notification with processId to processNotifications', () => { + const processNotification = new ProcessNotification( + 'proc-store-1', NotificationType.Info, 'process-789', 5000, 'Store process', + ); + + store.setState({ + core: { + notifications: [ + ...initialState.core.notifications, + processNotification, + ], + }, + }); + fixture.detectChanges(); + + expect(comp.processNotifications.length).toBe(1); + expect(comp.processNotifications[0].processId).toBe('process-789'); + expect(comp.notifications.length).toBe(2); + }); + + it('should render ds-process-notification elements for process notifications', () => { + const processNotification = new ProcessNotification( + 'proc-store-2', NotificationType.Info, 'process-render', 5000, 'Render test', + ); + + store.setState({ + core: { + notifications: [ + ...initialState.core.notifications, + processNotification, + ], + }, + }); + fixture.detectChanges(); + + const processElements = fixture.debugElement.queryAll(By.css('ds-process-notification')); + expect(processElements.length).toBe(1); + }); + + it('should remove a process notification when it is removed from the store', () => { + const processNotification = new ProcessNotification( + 'proc-store-3', NotificationType.Info, 'process-remove', 5000, 'To be removed', + ); + + store.setState({ + core: { + notifications: [ + ...initialState.core.notifications, + processNotification, + ], + }, + }); + fixture.detectChanges(); + expect(comp.processNotifications.length).toBe(1); + + store.setState({ + core: { + notifications: [], + }, + }); + fixture.detectChanges(); + + expect(comp.processNotifications.length).toBe(0); + expect(comp.notifications.length).toBe(0); + }); + }); + }) ; diff --git a/src/app/notification-system/notifications-board/notifications-board.component.ts b/src/app/notification-system/notifications-board/notifications-board.component.ts index dadce9ebcec..0c02d16ebcf 100644 --- a/src/app/notification-system/notifications-board/notifications-board.component.ts +++ b/src/app/notification-system/notifications-board/notifications-board.component.ts @@ -11,6 +11,7 @@ import { import { INotificationBoardOptions } from '@dspace/config/notifications-config.interfaces'; import { CoreState } from '@dspace/core/core-state.model'; import { INotification } from '@dspace/core/notification-system/models/notification.model'; +import { IProcessNotification } from '@dspace/core/notification-system/models/process-notification.model'; import { NotificationsState } from '@dspace/core/notification-system/notifications.reducers'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { notificationsStateSelector } from '@dspace/core/notification-system/selectors'; @@ -24,6 +25,7 @@ import { } from '@ngrx/store'; import cloneDeep from 'lodash/cloneDeep'; import differenceWith from 'lodash/differenceWith'; +import isEqual from 'lodash/isEqual'; import { BehaviorSubject, of, @@ -34,6 +36,7 @@ import { take } from 'rxjs/operators'; import { AccessibilitySettingsService } from '../../accessibility/accessibility-settings.service'; import { LiveRegionService } from '../../shared/live-region/live-region.service'; import { NotificationComponent } from '../notification/notification.component'; +import { ProcessNotificationComponent } from '../process-notification/process-notification.component'; @Component({ selector: 'ds-notifications-board', @@ -44,6 +47,7 @@ import { NotificationComponent } from '../notification/notification.component'; imports: [ NgClass, NotificationComponent, + ProcessNotificationComponent, ], }) export class NotificationsBoardComponent implements OnInit, OnDestroy { @@ -54,6 +58,7 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { } public notifications: INotification[] = []; + public processNotifications: IProcessNotification[] = []; public position: ['top' | 'bottom' | 'middle', 'right' | 'left' | 'center'] = ['bottom', 'right']; // Received values @@ -83,18 +88,25 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { .subscribe((state: NotificationsState) => { if (state.length === 0) { this.notifications = []; + this.processNotifications = []; } else if (state.length > this.notifications.length) { // Add - const newElem = differenceWith(state, this.notifications, this.byId); - newElem.forEach((notification) => { - this.add(notification); + const newElem = differenceWith(state, [...this.notifications,...this.processNotifications], this.byId); + newElem.forEach((notification: IProcessNotification) => { + + if ('processId' in notification) { + this.addProccess(notification); + } else { + this.add(notification); + } + }); } else { // Remove - const delElem = differenceWith(this.notifications, state, this.byId); + const delElem = differenceWith([...this.notifications,...this.processNotifications], state, this.byId); delElem.forEach((notification) => { this.notifications = this.notifications.filter((item: INotification) => item.id !== notification.id); - + this.processNotifications = this.processNotifications.filter((item: INotification) => item.id !== notification.id); }); } this.cdr.detectChanges(); @@ -104,6 +116,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { private byId = (notificationA: INotification, notificationB: INotification) => notificationA.id === notificationB.id; + // Add the new process notification to the processNotifications array + addProccess(item: IProcessNotification): void { + this.processNotifications.push(item); + } + // Add the new notification to the notification array add(item: INotification): void { const toBlock: boolean = this.block(item); @@ -177,11 +194,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { } private checkStandard(checker: INotification, item: INotification): boolean { - return checker.type === item.type && checker.title === item.title && checker.content === item.content; + return checker.type === item.type && checker.title === item.title && isEqual(checker.content, item.content); } private checkHtml(checker: INotification, item: INotification): boolean { - return checker.html ? checker.type === item.type && checker.title === item.title && checker.content === item.content && checker.html === item.html : false; + return checker.html ? checker.type === item.type && checker.title === item.title && isEqual(checker.content, item.content) && checker.html === item.html : false; } // Attach all the changes received in the options object diff --git a/src/app/notification-system/process-notification/process-notification.component.html b/src/app/notification-system/process-notification/process-notification.component.html new file mode 100644 index 00000000000..d301380dd10 --- /dev/null +++ b/src/app/notification-system/process-notification/process-notification.component.html @@ -0,0 +1,65 @@ + diff --git a/src/app/notification-system/process-notification/process-notification.component.scss b/src/app/notification-system/process-notification/process-notification.component.scss new file mode 100644 index 00000000000..06c46b0f5d0 --- /dev/null +++ b/src/app/notification-system/process-notification/process-notification.component.scss @@ -0,0 +1,32 @@ +.notification { + display: inline-block; + min-width: var(--bs-modal-sm); + text-align: left; +} + +.close { + outline: none !important +} + +.notification-icon { + min-width: 3rem; +} + +.notification-progress-loader { + top: -1px; + left: 0; + height: 1px; +} + +.alert-success .notification-progress-loader span { + background: var(--ds-notification-bg-success); +} +.alert-danger .notification-progress-loader span { + background: var(--ds-notification-bg-danger); +} +.alert-info .notification-progress-loader span { + background: var(--ds-notification-bg-info); +} +.alert-warning .notification-progress-loader span { + background: var(--ds-notification-bg-warning); +} diff --git a/src/app/notification-system/process-notification/process-notification.component.spec.ts b/src/app/notification-system/process-notification/process-notification.component.spec.ts new file mode 100644 index 00000000000..f128cd5a3e5 --- /dev/null +++ b/src/app/notification-system/process-notification/process-notification.component.spec.ts @@ -0,0 +1,152 @@ +import { + ChangeDetectorRef, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { AppConfig } from '@dspace/config/app-config.interface'; +import { INotificationBoardOptions } from '@dspace/config/notifications-config.interfaces'; +import { ProcessDataService } from '@dspace/core/data/processes/process-data.service'; +import { NotificationOptions } from '@dspace/core/notification-system/models/notification-options.model'; +import { NotificationType } from '@dspace/core/notification-system/models/notification-type'; +import { IProcessNotification } from '@dspace/core/notification-system/models/process-notification.model'; +import { notificationsReducer } from '@dspace/core/notification-system/notifications.reducers'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; + +import { storeModuleConfig } from '../../app.reducer'; +import { ProcessNotificationComponent } from './process-notification.component'; + +describe('ProcessNotificationComponent', () => { + + let comp: ProcessNotificationComponent; + let fixture: ComponentFixture; + let deTitle: DebugElement; + let elTitle: HTMLElement; + let deContent: DebugElement; + let elContent: HTMLElement; + + const processService = jasmine.createSpyObj('processService', { + getFiles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getProcess: createSuccessfulRemoteDataObject$({ processStatus: '' }), + }); + + beforeEach(waitForAsync(() => { + const store: Store = jasmine.createSpyObj('store', { + /* eslint-disable no-empty, @typescript-eslint/no-empty-function */ + notifications: [], + }); + const envConfig: Partial = { + notifications: { + rtl: false, + position: ['top', 'right'], + maxStack: 8, + timeOut: 5000, + clickToClose: true, + animate: 'scale', + } as INotificationBoardOptions, + } as any; + + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + BrowserAnimationsModule, + StoreModule.forRoot({ notificationsReducer }, storeModuleConfig), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ProcessNotificationComponent, + ], // declare the test component + providers: [ + { provide: Store, useValue: store }, + { provide: ProcessDataService, useValue: processService }, + ChangeDetectorRef, + NotificationsService, + TranslateService, + ], + }).compileComponents(); // compile template and css + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProcessNotificationComponent); + comp = fixture.componentInstance; + comp.notification = { + id: '1', + type: NotificationType.Info, + title: 'Notif. title', + content: 'Notif. content', + options: new NotificationOptions(), + } as IProcessNotification; + + fixture.detectChanges(); + + deTitle = fixture.debugElement.query(By.css('.notification-title')); + elTitle = deTitle.nativeElement; + deContent = fixture.debugElement.query(By.css('.notification-content')); + elContent = deContent.nativeElement; + }); + + it('should create component', () => { + expect(comp).toBeTruthy(); + }); + + it('should set Title', () => { + fixture.detectChanges(); + expect(elTitle.textContent.trim()).toBe((comp.notification.title as string).trim()); + }); + + it('should set Content', () => { + fixture.detectChanges(); + expect(elContent.textContent.trim()).toBe('process.new.notification.process.processing'); + }); + + it('Should display files section when finished is true and files are present', () => { + comp.finished = new BehaviorSubject(true); + comp.files$ = new BehaviorSubject([{ name: 'file1', sizeBytes: 1024 }]) as BehaviorSubject; + fixture.detectChanges(); + const filesSection = fixture.debugElement.query(By.css('.notification-content')); + expect(filesSection).toBeTruthy(); + }); + + it('Should not display files section when finished is false', () => { + comp.finished = new BehaviorSubject(false); + comp.files$ = new BehaviorSubject([{ name: 'file1', sizeBytes: 1024 }]) as BehaviorSubject; + fixture.detectChanges(); + const filesSection = fixture.debugElement.query(By.css('.notification-content[data-test="files-content"]')); + expect(filesSection).toBeFalsy(); + }); + + it('Should not display files section when no files are present', () => { + comp.finished = new BehaviorSubject(true); + comp.files$ = new BehaviorSubject([]); + fixture.detectChanges(); + const filesSection = fixture.debugElement.query(By.css('.notification-content[data-test="files-content"]')); + expect(filesSection).toBeFalsy(); + }); + +}); diff --git a/src/app/notification-system/process-notification/process-notification.component.ts b/src/app/notification-system/process-notification/process-notification.component.ts new file mode 100644 index 00000000000..fea80543d2b --- /dev/null +++ b/src/app/notification-system/process-notification/process-notification.component.ts @@ -0,0 +1,310 @@ +import { trigger } from '@angular/animations'; +import { + AsyncPipe, + NgTemplateOutlet, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, + TemplateRef, + ViewEncapsulation, +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { NotificationAnimationsStatus } from '@dspace/config/notifications-config.interfaces'; +import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; +import { ProcessDataService } from '@dspace/core/data/processes/process-data.service'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { IProcessNotification } from '@dspace/core/notification-system/models/process-notification.model'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { Process } from '@dspace/core/processes/process.model'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { + getAllCompletedRemoteData, + getFirstCompletedRemoteData, +} from '@dspace/core/shared/operators'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + of, + Subscription, + timer, +} from 'rxjs'; +import { + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { + fadeInEnter, + fadeInState, + fadeOutLeave, + fadeOutState, +} from '../../shared/animations/fade'; +import { + fromBottomEnter, + fromBottomInState, + fromBottomLeave, + fromBottomOutState, +} from '../../shared/animations/fromBottom'; +import { + fromLeftEnter, + fromLeftInState, + fromLeftLeave, + fromLeftOutState, +} from '../../shared/animations/fromLeft'; +import { + fromRightEnter, + fromRightInState, + fromRightLeave, + fromRightOutState, +} from '../../shared/animations/fromRight'; +import { + fromTopEnter, + fromTopInState, + fromTopLeave, + fromTopOutState, +} from '../../shared/animations/fromTop'; +import { + rotateEnter, + rotateInState, + rotateLeave, + rotateOutState, +} from '../../shared/animations/rotate'; +import { + scaleEnter, + scaleInState, + scaleLeave, + scaleOutState, +} from '../../shared/animations/scale'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; +import { ThemedFileDownloadLinkComponent } from '../../shared/file-download-link/themed-file-download-link.component'; +import { FileSizePipe } from '../../shared/utils/file-size-pipe'; + +@Component({ + selector: 'ds-process-notification', + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('enterLeave', [ + fadeInEnter, fadeInState, fadeOutLeave, fadeOutState, + fromBottomEnter, fromBottomInState, fromBottomLeave, fromBottomOutState, + fromRightEnter, fromRightInState, fromRightLeave, fromRightOutState, + fromLeftEnter, fromLeftInState, fromLeftLeave, fromLeftOutState, + fromTopEnter, fromTopInState, fromTopLeave, fromTopOutState, + rotateInState, rotateEnter, rotateOutState, rotateLeave, + scaleInState, scaleEnter, scaleOutState, scaleLeave, + ]), + ], + templateUrl: './process-notification.component.html', + styleUrls: ['./process-notification.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncPipe, + BtnDisabledDirective, + FileSizePipe, + NgTemplateOutlet, + ThemedFileDownloadLinkComponent, + TranslateModule, + ], +}) + +export class ProcessNotificationComponent implements OnInit, OnDestroy { + + /** + * Notification that is being processed. + */ + @Input() public notification: IProcessNotification = null; + + /** + * Title of the notification. + */ + public title: Observable; + + /** + * Is title an html or a simple text. + */ + public html: any; + + /** + * Is title an html or a simple text.. + */ + public titleIsTemplate = false; + + /** + * Animation of the notification. + */ + public animate: string; + + /** + * Subscription for timer. + */ + private sub: Subscription; + + /** + * The process that is being checked. + */ + public processStatus$: BehaviorSubject = new BehaviorSubject(''); + + /** + * If process checking is finished. + */ + public finished: BehaviorSubject = new BehaviorSubject(false); + + /** + * Files generated from process end. + */ + public files$: BehaviorSubject = new BehaviorSubject([]); + + /** + * Type of the notification visualisation. + */ + public notificationType$: BehaviorSubject = new BehaviorSubject('alert-info'); + + constructor(private notificationService: NotificationsService, + private domSanitizer: DomSanitizer, + protected processService: ProcessDataService, + protected nameService: DSONameService, + private cdr: ChangeDetectorRef, + ) { + } + + /** + * On init, start check process, and insert notifications information. + */ + ngOnInit(): void { + this.animate = this.notification.options.animate + NotificationAnimationsStatus.In; + this.pollUntilProcessFinished(); + this.html = this.notification.html; + this.contentType(this.notification.title, 'title'); + } + + /** + * Poll process endpoint until it's finished. + */ + pollUntilProcessFinished() { + timer(0, this.notification.checkTime).pipe( + switchMap(() => this.processService.getProcess(this.notification.processId)), + getAllCompletedRemoteData(), + filter((res: RemoteData) => res.hasFailed || res?.payload?.processStatus.toString() === 'COMPLETED' || res?.payload?.processStatus.toString() === 'FAILED'), + take(1), + tap((res: RemoteData) => this.pollingFinishedFor(res)), + switchMap((res: RemoteData) => this.getFiles(res)), + ).subscribe((files: Bitstream[]) => { + const logFiles = files.filter( (file) => !this.getFileName(file).includes('.log')); + this.files$.next(logFiles); + this.finished.next(true); + }); + } + + /** + * Handle process results + * + * @param processRD The RemoteData object for finished process + */ + pollingFinishedFor(processRD: RemoteData) { + if (processRD.hasSucceeded && processRD.payload.processStatus.toString() === 'COMPLETED') { + this.notificationType$.next('alert-success'); + this.processStatus$.next('process.new.notification.process.status.completed'); + } else { + this.processStatus$.next('process.new.notification.process.status.failed'); + this.notificationType$.next('alert-danger'); + } + } + + /** + * When the process is completed get the files output. + */ + getFiles(processRD: RemoteData): Observable { + if (processRD.hasSucceeded && processRD.payload.processStatus.toString() === 'COMPLETED') { + return this.processService.getFiles(processRD.payload.processId).pipe( + getFirstCompletedRemoteData(), + map((response) => response.hasSucceeded ? response.payload.page : []), + ); + } else { + return of([]); + } + } + + /** + * Get the name of a bitstream + * @param bitstream + */ + getFileName(bitstream: Bitstream) { + return bitstream instanceof DSpaceObject ? this.nameService.getName(bitstream) : 'unknown'; + } + + /** + * On destroy stop timer. + */ + ngOnDestroy(): void { + if (this.sub) { + this.sub.unsubscribe(); + } + } + + /** + * Remove notification from view using notification service. + */ + public remove() { + + if (this.animate) { + this.setAnimationOut(); + setTimeout(() => { + this.notificationService.remove(this.notification); + }, 1000); + } else { + this.notificationService.remove(this.notification); + } + + if (this.sub) { + this.sub.unsubscribe(); + } + } + + /** + * Checks if content is html or normal text or observable. + * @param item + * @param key + */ + private contentType(item: any, key: string) { + if (item instanceof TemplateRef) { + this[key] = item; + } else if (key === 'title' || (key === 'content' && !this.html)) { + let value = null; + if (isNotEmpty(item)) { + if (typeof item === 'string') { + value = of(item); + } else if (item instanceof Observable) { + value = item; + } else if (typeof item === 'object' && isNotEmpty(item.value)) { + // when notifications state is transferred from SSR to CSR, + // Observables Object loses the instance type and become simply object, + // so converts it again to Observable + value = of(item.value); + } + } + this[key] = value; + } else { + this[key] = this.domSanitizer.bypassSecurityTrustHtml(item); + } + + this[key + 'IsTemplate'] = item instanceof TemplateRef; + } + + /** + * Animation of notification on close. + */ + private setAnimationOut() { + this.animate = this.notification.options.animate + NotificationAnimationsStatus.Out; + this.cdr.detectChanges(); + } +} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.html b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.html new file mode 100644 index 00000000000..14aa6de0ecb --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.html @@ -0,0 +1,28 @@ +@if (hasSubSections$ | async) { +
+
+ + +
+
+} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.scss b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.scss new file mode 100644 index 00000000000..106ccff11a2 --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.scss @@ -0,0 +1,41 @@ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} + +.dso-button-menu { + .dropdown-toggle::after { + content: ''; + width: 0; + height: 0; + border-style: solid; + border-width: 12px 12px 0 0; + border-color: transparent #627a91 transparent transparent; + border-bottom-right-radius: var(--bs-btn-border-radius-sm); + right: 0; + bottom: 0; + position: absolute; + overflow: hidden; + } + overflow: hidden; +} + +ul.dropdown-menu { + background-color: var(--ds-admin-sidebar-bg); + color: white; + + ::ng-deep a { + color: white; + + &.disabled { + color: var(--bs-btn-link-disabled-color); + } + } + + .disabled { + color: var(--bs-btn-link-disabled-color); + } +} + +.dso-public-menu-dropdown { + max-width: calc(min(600px, 75vw)); +} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.spec.ts b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.spec.ts new file mode 100644 index 00000000000..9bd6947da68 --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.spec.ts @@ -0,0 +1,116 @@ +import { Component } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { CSSVariableServiceStub } from '@dspace/core/testing/css-variable-service.stub'; +import { RouterStub } from '@dspace/core/testing/router.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { MenuService } from '../../../menu/menu.service'; +import { MenuItemType } from '../../../menu/menu-item-type.model'; +import { MenuItemModels } from '../../../menu/menu-section.model'; +import { MenuServiceStub } from '../../../menu/menu-service.stub'; +import { CSSVariableService } from '../../../sass-helper/css-variable.service'; +import { getMockThemeService } from '../../../theme-support/test/theme-service.mock'; +import { ThemeService } from '../../../theme-support/theme.service'; +import { DsoPublicMenuExpandableSectionComponent } from './dso-public-menu-expandable-section.component'; + +describe('DsoPublicMenuExpandableSectionComponent', () => { + let component: DsoPublicMenuExpandableSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + const iconString = 'test'; + + const dummySection = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + disabled: false, + text: 'text', + }, + icon: iconString, + }; + + describe('when there are subsections', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), DsoPublicMenuExpandableSectionComponent, TestComponent], + providers: [ + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: Router, useValue: new RouterStub() }, + { provide: ThemeService, useValue: getMockThemeService() }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(of([{ + id: 'test', + visible: true, + model: {} as MenuItemModels, + }])); + fixture = TestBed.createComponent(DsoPublicMenuExpandableSectionComponent); + component = fixture.componentInstance; + component.section = dummySection; + component.itemModel = dummySection.model; + spyOn(component, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show a button with the icon', () => { + const button = fixture.debugElement.query(By.css('.btn-dark')); + expect(button.nativeElement.innerHTML).toContain('fa-' + iconString); + }); + }); + + describe('when there are no subsections', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), DsoPublicMenuExpandableSectionComponent, TestComponent], + providers: [ + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: Router, useValue: new RouterStub() }, + { provide: ThemeService, useValue: getMockThemeService() }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(of([])); + fixture = TestBed.createComponent(DsoPublicMenuExpandableSectionComponent); + component = fixture.componentInstance; + component.section = dummySection; + spyOn(component, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should now show a button', () => { + const button = fixture.debugElement.query(By.css('.btn-dark')); + expect(button).toBeNull(); + }); + }); +}); + +@Component({ + selector: 'ds-test-cmp', + template: ``, +}) +class TestComponent { +} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.ts b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.ts new file mode 100644 index 00000000000..09faeb2ff4a --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-expandable-menu-section/dso-public-menu-expandable-section.component.ts @@ -0,0 +1,99 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + AsyncPipe, + NgComponentOutlet, +} from '@angular/common'; +import { + Component, + Injector, + OnInit, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { + hasValue, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { + NgbDropdownModule, + NgbTooltip, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { MenuID } from 'src/app/shared/menu/menu-id.model'; +import { MenuSection } from 'src/app/shared/menu/menu-section.model'; +import { AbstractMenuSectionComponent } from 'src/app/shared/menu/menu-section/abstract-menu-section.component'; + +import { BtnDisabledDirective } from '../../../btn-disabled.directive'; +import { MenuService } from '../../../menu/menu.service'; +import { rendersSectionForMenu } from '../../../menu/menu-section.decorator'; +import { ThemeService } from '../../../theme-support/theme.service'; + +/** + * Represents an expandable section in the dso public menus + */ +@Component({ + selector: 'ds-dso-public-menu-expandable-section', + templateUrl: './dso-public-menu-expandable-section.component.html', + styleUrls: ['./dso-public-menu-expandable-section.component.scss'], + imports: [ + AsyncPipe, + BtnDisabledDirective, + NgbDropdownModule, + NgbTooltip, + NgComponentOutlet, + TranslateModule, + ], +}) +@rendersSectionForMenu(MenuID.DSO_PUBLIC, true) +export class DsoPublicMenuExpandableSectionComponent extends AbstractMenuSectionComponent implements OnInit { + + /** + * This section resides in the DSO public menu + */ + menuID: MenuID = MenuID.DSO_PUBLIC; + + /** + * Emits whether one of the subsections contains an icon + */ + renderIcons$: Observable; + + /** + * Emits true when the top section has subsections, else emits false + */ + hasSubSections$: Observable; + + constructor( + protected menuService: MenuService, + protected injector: Injector, + protected themeService: ThemeService, + protected router: Router, + ) { + super( + menuService, + injector, + themeService, + ); + } + + ngOnInit(): void { + this.menuService.activateSection(this.menuID, this.section.id); + super.ngOnInit(); + + this.renderIcons$ = this.subSections$.pipe( + map((sections: MenuSection[]) => { + return sections.some(section => hasValue(section.icon)); + }), + ); + + this.hasSubSections$ = this.subSections$.pipe( + map((subSections) => isNotEmpty(subSections)), + ); + } +} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.html b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.html new file mode 100644 index 00000000000..b2c8ca84ce9 --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.html @@ -0,0 +1,29 @@ +@if (!canActivate) { +
+ @if (!section.model.disabled) { + + + {{itemModel.text | translate}} + + } + @if (section.model.disabled) { + + } +
+} + +@if (canActivate) { +
+ +
+} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.scss b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.scss new file mode 100644 index 00000000000..cf0e81c5538 --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.scss @@ -0,0 +1,3 @@ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.spec.ts b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.spec.ts new file mode 100644 index 00000000000..60afdb9036e --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.spec.ts @@ -0,0 +1,186 @@ +import { Component } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; +import { CSSVariableServiceStub } from '@dspace/core/testing/css-variable-service.stub'; +import { RouterStub } from '@dspace/core/testing/router.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { MenuItemType } from 'src/app/shared/menu/menu-item-type.model'; + +import { MenuService } from '../../../menu/menu.service'; +import { OnClickMenuItemModel } from '../../../menu/menu-item/models/onclick.model'; +import { MenuServiceStub } from '../../../menu/menu-service.stub'; +import { CSSVariableService } from '../../../sass-helper/css-variable.service'; +import { getMockThemeService } from '../../../theme-support/test/theme-service.mock'; +import { ThemeService } from '../../../theme-support/theme.service'; +import { DsoPublicMenuSectionComponent } from './dso-public-menu-section.component'; + +function initAsync(menuService: MenuServiceStub) { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + DsoPublicMenuSectionComponent, + TestComponent, + ], + providers: [ + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: ThemeService, useValue: getMockThemeService() }, + ], + }).compileComponents(); + })); +} + +describe('DsoPublicMenuSectionComponent', () => { + let component: DsoPublicMenuSectionComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + const iconString = 'test'; + + const dummySectionText = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + disabled: false, + text: 'text', + }, + icon: iconString, + }; + const dummySectionLink = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + disabled: false, + text: 'text', + link: 'link', + }, + icon: iconString, + }; + const dummySectionClick = { + id: 'dummy', + active: false, + visible: true, + model: { + type: MenuItemType.ONCLICK, + disabled: false, + text: 'text', + function: () => 'test', + }, + icon: iconString, + }; + + describe('text model', () => { + initAsync(menuService); + + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(of([])); + fixture = TestBed.createComponent(DsoPublicMenuSectionComponent); + component = fixture.componentInstance; + component.section = dummySectionText; + component.itemModel = component.section.model; + spyOn(component, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show a button with the icon', () => { + const button = fixture.debugElement.query(By.css('.btn-dark')); + expect(button.nativeElement.innerHTML).toContain('fa-' + iconString); + }); + + describe('when the section model in a disabled link or text', () => { + it('should show just the button', () => { + const textButton = fixture.debugElement.query(By.css('div a')); + expect(textButton.nativeElement.innerHTML).toContain('fa-' + iconString); + }); + }); + }); + describe('on click model', () => { + initAsync(menuService); + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(of([])); + fixture = TestBed.createComponent(DsoPublicMenuSectionComponent); + component = fixture.componentInstance; + component.section = dummySectionClick; + component.itemModel = component.section.model; + spyOn(component, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); + fixture.detectChanges(); + }); + + describe('when the section model in an on click menu', () => { + it('should call the activate method when clicking the button', () => { + spyOn(component, 'activate'); + + const button = fixture.debugElement.query(By.css('.btn-dark')); + button.triggerEventHandler('click', null); + + expect(component.activate).toHaveBeenCalled(); + }); + }); + describe('activate', () => { + const mockEvent = jasmine.createSpyObj('event', { + preventDefault: jasmine.createSpy('preventDefault'), + stopPropagation: jasmine.createSpy('stopPropagation'), + }); + it('should call the item model function when not disabled', () => { + spyOn((component as any).section.model as OnClickMenuItemModel, 'function'); + component.activate(mockEvent); + + expect(((component as any).section.model as OnClickMenuItemModel).function).toHaveBeenCalled(); + }); + it('should call not the item model function when disabled', () => { + spyOn((component as any).section.model as OnClickMenuItemModel, 'function'); + component.itemModel.disabled = true; + component.activate(mockEvent); + + expect(((component as any).section.model as OnClickMenuItemModel).function).not.toHaveBeenCalled(); + component.itemModel.disabled = false; + }); + }); + + }); + + describe('when the section model in a non disabled link', () => { + initAsync(menuService); + beforeEach(() => { + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(of([])); + fixture = TestBed.createComponent(DsoPublicMenuSectionComponent); + component = fixture.componentInstance; + component.section = dummySectionLink; + component.itemModel = component.section.model; + spyOn(component, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); + fixture.detectChanges(); + }); + + it('should show the link element', () => { + expect(fixture.debugElement.query(By.css('a'))).not.toBeNull(); + }); + + }); +}); + +@Component({ + selector: 'ds-test-cmp', + template: ``, +}) +class TestComponent { +} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.ts b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.ts new file mode 100644 index 00000000000..7d627e66940 --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-menu-section/dso-public-menu-section.component.ts @@ -0,0 +1,74 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { + Component, + Injector, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { AbstractMenuSectionComponent } from 'src/app/shared/menu/menu-section/abstract-menu-section.component'; + +import { BtnDisabledDirective } from '../../../btn-disabled.directive'; +import { MenuService } from '../../../menu/menu.service'; +import { MenuID } from '../../../menu/menu-id.model'; +import { rendersSectionForMenu } from '../../../menu/menu-section.decorator'; +import { ThemeService } from '../../../theme-support/theme.service'; + +/** + * Represents a non-expandable section in the dso public menus + */ +@Component({ + selector: 'ds-dso-public-menu-section', + templateUrl: './dso-public-menu-section.component.html', + styleUrls: ['./dso-public-menu-section.component.scss'], + imports: [ + BtnDisabledDirective, + NgbTooltip, + RouterLink, + TranslateModule, + ], +}) +@rendersSectionForMenu(MenuID.DSO_PUBLIC, false) +export class DsoPublicMenuSectionComponent extends AbstractMenuSectionComponent implements OnInit { + + menuID: MenuID = MenuID.DSO_PUBLIC; + hasLink: boolean; + canActivate: boolean; + + constructor( + protected menuService: MenuService, + protected injector: Injector, + protected themeService: ThemeService, + ) { + super( + menuService, + injector, + themeService, + ); + } + + ngOnInit(): void { + this.hasLink = isNotEmpty(this.itemModel?.link); + this.canActivate = isNotEmpty(this.itemModel?.function); + super.ngOnInit(); + } + + /** + * Activate the section's model function + */ + public activate(event: any) { + event.preventDefault(); + if (!this.itemModel.disabled) { + this.itemModel.function(); + } + event.stopPropagation(); + } +} diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.html b/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.html new file mode 100644 index 00000000000..b5532ce5d96 --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.html @@ -0,0 +1,12 @@ +
+ @for (sectionDTO of (sectionDTOs$ | async); track sectionDTO) { +
+ + +
+ } +
diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.scss b/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.spec.ts b/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.spec.ts new file mode 100644 index 00000000000..727779c0001 --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.spec.ts @@ -0,0 +1,119 @@ +import { + Injector, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { MenuService } from '../../menu/menu.service'; +import { TextMenuItemModel } from '../../menu/menu-item/models/text.model'; +import { MenuServiceStub } from '../../menu/menu-service.stub'; +import { getMockThemeService } from '../../theme-support/test/theme-service.mock'; +import { ThemeService } from '../../theme-support/theme.service'; +import { DsoPublicMenuComponent } from './dso-public-menu.component'; + +describe('DsoPublicMenuComponent', () => { + let comp: DsoPublicMenuComponent; + let fixture: ComponentFixture; + const menuService = new MenuServiceStub(); + let authorizationService: AuthorizationDataService; + + const routeStub = { + children: [], + }; + + const section = { + id: 'public-dso', + active: false, + visible: true, + model: { + text: 'section-text', + type: null, + disabled: false, + } as TextMenuItemModel, + icon: 'eye', + index: 1, + }; + + const subSection = { + id: 'public-dso-sub', + active: false, + visible: true, + model: { + text: 'sub-section-text', + type: null, + disabled: false, + } as TextMenuItemModel, + icon: 'info-circle', + index: 0, + }; + + beforeEach(waitForAsync(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: of(true), + }); + spyOn(menuService, 'getMenuTopSections').and.returnValue(of([section])); + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(of([subSection])); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule, DsoPublicMenuComponent], + providers: [ + Injector, + { provide: MenuService, useValue: menuService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ThemeService, useValue: getMockThemeService() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + describe('onInit', () => { + it('should create', () => { + fixture = TestBed.createComponent(DsoPublicMenuComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + expect(comp).toBeTruthy(); + }); + + it('should have role menubar when subsections exist', () => { + (menuService.getSubSectionsByParentID as jasmine.Spy).and.returnValue(of([subSection])); + fixture = TestBed.createComponent(DsoPublicMenuComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + + const menu = fixture.nativeElement.querySelector('.dso-public-menu'); + expect(menu.getAttribute('role')).toBe('menubar'); + }); + + it('should NOT have role menubar when no subsections exist', () => { + (menuService.getSubSectionsByParentID as jasmine.Spy).and.returnValue(of([])); + fixture = TestBed.createComponent(DsoPublicMenuComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + + const menu = fixture.nativeElement.querySelector('.dso-public-menu'); + expect(menu.getAttribute('role')).toBeNull(); + }); + + it('should have aria-hidden when no subsections exist', () => { + (menuService.getSubSectionsByParentID as jasmine.Spy).and.returnValue(of([])); + fixture = TestBed.createComponent(DsoPublicMenuComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + + const menu = fixture.nativeElement.querySelector('.dso-public-menu'); + expect(menu.getAttribute('aria-hidden')).toBe('true'); + }); + }); +}); diff --git a/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.ts b/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.ts new file mode 100644 index 00000000000..95b7f49a91d --- /dev/null +++ b/src/app/shared/dso-page/dso-public-menu/dso-public-menu.component.ts @@ -0,0 +1,73 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Injector, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { + combineLatest, + Observable, + of, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { MenuComponent } from '../../menu/menu.component'; +import { MenuService } from '../../menu/menu.service'; +import { MenuComponentLoaderComponent } from '../../menu/menu-component-loader/menu-component-loader.component'; +import { MenuID } from '../../menu/menu-id.model'; +import { ThemeService } from '../../theme-support/theme.service'; + +/** + * Component that renders the DSO public menu. + * This menu holds item menu voices available to unauthenticated users. + */ +@Component({ + selector: 'ds-dso-public-menu', + styleUrls: ['./dso-public-menu.component.scss'], + templateUrl: './dso-public-menu.component.html', + imports: [ + AsyncPipe, + MenuComponentLoaderComponent, + ], +}) +export class DsoPublicMenuComponent extends MenuComponent { + + menuID = MenuID.DSO_PUBLIC; + + menuVisibleWithSections$: Observable; + + constructor( + protected menuService: MenuService, + protected injector: Injector, + public authorizationService: AuthorizationDataService, + public route: ActivatedRoute, + protected themeService: ThemeService, + ) { + super(menuService, injector, authorizationService, route, themeService); + this.menuVisibleWithSections$ = this.menuService.getMenuTopSections(MenuID.DSO_PUBLIC).pipe( + switchMap((sections) => { + if (sections.length === 0) {return of(false);} + return combineLatest( + sections.map((section) => + this.menuService.getSubSectionsByParentID(MenuID.DSO_PUBLIC, section.id).pipe( + map((subSections) => subSections.length > 0), + ), + ), + ).pipe( + map((results) => results.some((hasVisible) => hasVisible)), + ); + }), + ); + } +} diff --git a/src/app/shared/dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component.spec.ts new file mode 100644 index 00000000000..2f29ab88fb5 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component.spec.ts @@ -0,0 +1,92 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { CollectionDataService } from '@dspace/core/data/collection-data.service'; +import { FindListOptions } from '@dspace/core/data/find-list-options.model'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { Collection } from '@dspace/core/shared/collection.model'; +import { DSpaceObjectType } from '@dspace/core/shared/dspace-object-type.model'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; +import { SearchService } from 'src/app/shared/search/search.service'; + +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +import { ListableObjectComponentLoaderComponent } from '../../../object-collection/shared/listable-object/listable-object-component-loader.component'; +import { VarDirective } from '../../../utils/var.directive'; +import { AdministeredCollectionSelectorComponent } from './administered-collection-selector.component'; + +describe('AdministeredCollectionSelectorComponent', () => { + let component: AdministeredCollectionSelectorComponent; + let fixture: ComponentFixture; + + let collectionService; + let collection; + + let notificationsService: NotificationsService; + + const findOptions: FindListOptions = { currentPage: 1, elementsPerPage: 10 }; + + beforeEach(waitForAsync(() => { + collection = Object.assign(new Collection(), { + id: 'admin-collection', + }); + collectionService = jasmine.createSpyObj('collectionService', { + getAdministeredCollectionByEntityType: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), + getAdministeredCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error']); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), AdministeredCollectionSelectorComponent, VarDirective], + providers: [ + { provide: SearchService, useValue: {} }, + { provide: CollectionDataService, useValue: collectionService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(AdministeredCollectionSelectorComponent, { remove: { imports: [ListableObjectComponentLoaderComponent, ThemedLoadingComponent] } }).compileComponents(); + + fixture = TestBed.createComponent(AdministeredCollectionSelectorComponent); + component = fixture.componentInstance; + component.types = [DSpaceObjectType.COLLECTION]; + })); + + + + describe('search without provided entityTpe', () => { + + it('should call getAdministeredCollectionByEntityType and return the authorized collection in a SearchResult', (done) => { + + fixture.detectChanges(); + + component.search('', 1).subscribe((resultRD) => { + expect(collectionService.getAdministeredCollection).toHaveBeenCalledWith('', findOptions); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + done(); + }); + }); + }); + + describe('search with provided entityTpe', () => { + + + it('should call getAdministeredCollection and return the authorized collection in a SearchResult', (done) => { + + component.entityType = 'Publication'; + fixture.detectChanges(); + + component.search('', 1).subscribe((resultRD) => { + expect(collectionService.getAdministeredCollectionByEntityType).toHaveBeenCalledWith('', 'Publication', findOptions); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + done(); + }); + }); + }); + +}); diff --git a/src/app/shared/dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component.ts new file mode 100644 index 00000000000..667d7d20966 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component.ts @@ -0,0 +1,105 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; +import { CollectionDataService } from '@dspace/core/data/collection-data.service'; +import { FindListOptions } from '@dspace/core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '@dspace/core/data/paginated-list.model'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { CollectionSearchResult } from '@dspace/core/shared/object-collection/collection-search-result.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HoverClassDirective } from '../../../hover-class.directive'; +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +import { ListableObjectComponentLoaderComponent } from '../../../object-collection/shared/listable-object/listable-object-component-loader.component'; +import { SearchService } from '../../../search/search.service'; +import { DSOSelectorComponent } from '../dso-selector.component'; + +@Component({ + selector: 'ds-administered-collection-selector', + styleUrls: ['../dso-selector.component.scss'], + templateUrl: '../dso-selector.component.html', + imports: [ + AsyncPipe, + FormsModule, + HoverClassDirective, + InfiniteScrollModule, + ListableObjectComponentLoaderComponent, + NgClass, + ReactiveFormsModule, + ThemedLoadingComponent, + TranslateModule, + ], +}) +/** + * Component rendering a list of collections to select from + */ +export class AdministeredCollectionSelectorComponent extends DSOSelectorComponent { + + constructor(protected collectionDataService: CollectionDataService, + protected searchService: SearchService, + protected notifcationsService: NotificationsService, + protected translate: TranslateService, + protected dsoNameService: DSONameService) { + super(searchService, notifcationsService, translate, dsoNameService); + } + + /** + * If present this value is used to filter collection list by entity type + */ + @Input() entityType: string; + + /** + * Get a query to send for retrieving the current DSO + */ + getCurrentDSOQuery(): string { + return this.currentDSOId; + } + + /** + * Perform a search for administered collections with the current query and page + * @param query Query to search objects for + * @param page Page to retrieve + */ + search(query: string, page: number): Observable>>> { + const findOptions: FindListOptions = { + currentPage: page, + elementsPerPage: this.defaultPagination.pageSize, + }; + + const search$ = this.entityType + ? this.collectionDataService.getAdministeredCollectionByEntityType(query,this.entityType, findOptions) + : this.collectionDataService.getAdministeredCollection(query, findOptions); + + return search$.pipe( + getFirstCompletedRemoteData(), + map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, { + payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col }))) : null, + })), + ); + } + +} diff --git a/src/app/shared/listable.module.ts b/src/app/shared/listable.module.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/menu/initial-menus-state.ts b/src/app/shared/menu/initial-menus-state.ts index 4ddef8d276a..07f0d1c89e9 100644 --- a/src/app/shared/menu/initial-menus-state.ts +++ b/src/app/shared/menu/initial-menus-state.ts @@ -32,4 +32,13 @@ export const initialMenusState: MenusState = { sections: {}, sectionToSubsectionIndex: {}, }, + [MenuID.DSO_PUBLIC]: + { + id: MenuID.DSO_PUBLIC, + collapsed: true, + previewCollapsed: true, + visible: true, + sections: {}, + sectionToSubsectionIndex: {}, + }, }; diff --git a/src/app/shared/menu/menu-id.model.ts b/src/app/shared/menu/menu-id.model.ts index 547071aa040..2ca8f52498e 100644 --- a/src/app/shared/menu/menu-id.model.ts +++ b/src/app/shared/menu/menu-id.model.ts @@ -4,5 +4,6 @@ export enum MenuID { ADMIN = 'admin-sidebar', PUBLIC = 'public', - DSO_EDIT = 'dso-edit' + DSO_EDIT = 'dso-edit', + DSO_PUBLIC = 'dso-public', } diff --git a/src/app/shared/menu/menu-provider.service.spec.ts b/src/app/shared/menu/menu-provider.service.spec.ts index 5f68e76c224..7b0e39d7596 100644 --- a/src/app/shared/menu/menu-provider.service.spec.ts +++ b/src/app/shared/menu/menu-provider.service.spec.ts @@ -168,6 +168,7 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_PUBLIC, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); @@ -189,6 +190,7 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_PUBLIC, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); @@ -213,6 +215,7 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_PUBLIC, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); @@ -234,6 +237,7 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_PUBLIC, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); @@ -256,6 +260,7 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_PUBLIC, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); @@ -275,6 +280,7 @@ describe('MenuProviderService', () => { expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.ADMIN, sectionToBeRemoved.id); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_EDIT, sectionToBeRemoved.id); + expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.DSO_PUBLIC, sectionToBeRemoved.id); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider1.menuID, expectedSection1); expect(menuService.addSection).not.toHaveBeenCalledWith(persistentProvider2.menuID, expectedSection21); diff --git a/src/app/shared/menu/menu.structure.spec.ts b/src/app/shared/menu/menu.structure.spec.ts index 508bc13b1fe..a704bf6f138 100644 --- a/src/app/shared/menu/menu.structure.spec.ts +++ b/src/app/shared/menu/menu.structure.spec.ts @@ -68,6 +68,8 @@ describe('buildMenuStructure', () => { ), ]), ], + [MenuID.DSO_PUBLIC]: [ + ], }; const orderedProviderTypeList = diff --git a/src/app/shared/menu/providers/export-item.menu.spec.ts b/src/app/shared/menu/providers/export-item.menu.spec.ts new file mode 100644 index 00000000000..d7d3ebec0ad --- /dev/null +++ b/src/app/shared/menu/providers/export-item.menu.spec.ts @@ -0,0 +1,129 @@ +import { TestBed } from '@angular/core/testing'; +import { Item } from '@dspace/core/shared/item.model'; +import { ITEM } from '@dspace/core/shared/item.resource-type'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; + +import { ItemExportService } from '../../search/item-export/item-export.service'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { ExportItemMenuProvider } from './export-item.menu'; + +describe('ExportItemMenuProvider', () => { + + const expectedVisibleSection: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'item.page.export', + disabled: false, + function: jasmine.any(Function) as any, + }, + icon: 'file-export', + }, + ]; + + const expectedHiddenSection: PartialMenuSection[] = [ + { + visible: false, + model: { + type: MenuItemType.ONCLICK, + text: 'item.page.export', + disabled: false, + function: jasmine.any(Function) as any, + }, + icon: 'file-export', + }, + ]; + + let provider: ExportItemMenuProvider; + + const item: Item = Object.assign(new Item(), { + type: ITEM.value, + _links: { self: { href: 'self-link' } }, + metadata: { + 'dc.title': [{ + value: 'Test Item', + }], + 'dspace.entity.type': [{ + value: 'Publication', + }], + }, + }); + + let itemExportService; + let ngbModal; + + beforeEach(() => { + itemExportService = jasmine.createSpyObj('ItemExportService', { + initialItemExportFormConfiguration: of({ + entityType: 'Publication', + format: { id: 'csv', mimeType: 'text/csv', entityType: 'Publication', molteplicity: 'SINGLE' }, + entityTypes: null, + formats: [{ id: 'csv', mimeType: 'text/csv', entityType: 'Publication', molteplicity: 'SINGLE' }], + }), + }); + + ngbModal = jasmine.createSpyObj('NgbModal', { + open: { + componentInstance: { molteplicity: null, item: null, showListSelection: null }, + }, + }); + + TestBed.configureTestingModule({ + providers: [ + ExportItemMenuProvider, + { provide: ItemExportService, useValue: itemExportService }, + { provide: NgbModal, useValue: ngbModal }, + ], + }); + provider = TestBed.inject(ExportItemMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return a visible section when export formats are available', (done) => { + provider.getSectionsForContext(item).subscribe((sections) => { + expect(sections).toEqual(expectedVisibleSection); + done(); + }); + }); + + it('should return a hidden section when no export formats are available', (done) => { + (itemExportService.initialItemExportFormConfiguration as jasmine.Spy).and.returnValue(of({ + entityType: 'Publication', + format: null, + entityTypes: null, + formats: [], + })); + + provider.getSectionsForContext(item).subscribe((sections) => { + expect(sections).toEqual(expectedHiddenSection); + done(); + }); + }); + + it('should return an empty array when dso is not an Item', (done) => { + const nonItem = { type: 'community' } as any; + provider.getSectionsForContext(nonItem).subscribe((sections) => { + expect(sections).toEqual([]); + done(); + }); + }); + }); + + describe('openExportModal', () => { + it('should open the ItemExportComponent modal when the section function is called', (done) => { + provider.getSectionsForContext(item).subscribe((sections) => { + // Call the onclick function + (sections[0].model as any).function(); + expect(ngbModal.open).toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/src/app/shared/menu/providers/export-item.menu.ts b/src/app/shared/menu/providers/export-item.menu.ts new file mode 100644 index 00000000000..49c2d8d856c --- /dev/null +++ b/src/app/shared/menu/providers/export-item.menu.ts @@ -0,0 +1,86 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Injectable } from '@angular/core'; +import { ItemExportFormatMolteplicity } from '@dspace/core/itemexportformat/item-export-format.service'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + Observable, + of, +} from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { + ItemExportFormConfiguration, + ItemExportService, +} from '../../search/item-export/item-export.service'; +import { ItemExportComponent } from '../../search/item-export/item-export/item-export.component'; +import { OnClickMenuItemModel } from '../menu-item/models/onclick.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; + +/** + * Menu provider that adds an "Export Item" action to the DSO public menu. + * Available to all users (including unauthenticated), visibility depends on whether + * export formats are configured for the item's entity type. + */ +@Injectable() +export class ExportItemMenuProvider extends DSpaceObjectPageMenuProvider { + + constructor( + private modalService: NgbModal, + private itemExportService: ItemExportService, + ) { + super(); + } + + public getSectionsForContext(dso: DSpaceObject): Observable { + if (!(dso instanceof Item)) { + return of([]); + } + + const item = dso as Item; + + return this.itemExportService.initialItemExportFormConfiguration(item).pipe( + take(1), + map((config: ItemExportFormConfiguration) => { + const hasFormats = config?.formats?.length > 0; + + return [ + { + visible: hasFormats, + model: { + type: MenuItemType.ONCLICK, + text: 'item.page.export', + disabled: false, + function: () => { + this.openExportModal(item); + }, + } as OnClickMenuItemModel, + icon: 'file-export', + }, + ] as PartialMenuSection[]; + }), + ); + } + + /** + * Open the export modal for the given item + */ + private openExportModal(item: Item): void { + const modalRef = this.modalService.open(ItemExportComponent); + modalRef.componentInstance.molteplicity = ItemExportFormatMolteplicity.SINGLE; + modalRef.componentInstance.item = item; + modalRef.componentInstance.showListSelection = false; + } +} diff --git a/src/app/shared/object-list/selectable-list/selectable-list.actions.ts b/src/app/shared/object-list/selectable-list/selectable-list.actions.ts index 9808a53d8e2..2ad4f50b9d9 100644 --- a/src/app/shared/object-list/selectable-list/selectable-list.actions.ts +++ b/src/app/shared/object-list/selectable-list/selectable-list.actions.ts @@ -18,6 +18,7 @@ export const SelectableListActionTypes = { DESELECT_SINGLE: type('dspace/selectable-lists/DESELECT_SINGLE'), SET_SELECTION: type('dspace/selectable-lists/SET_SELECTION'), DESELECT_ALL: type('dspace/selectable-lists/DESELECT_ALL'), + REMOVE_SELECTION: type('dspace/selectable-lists/REMOVE_SELECTION'), }; /** @@ -98,3 +99,12 @@ export class SelectableListDeselectAllAction extends SelectableListAction { super(SelectableListActionTypes.DESELECT_ALL, id); } } + +/** + * Action to remove a selection list from state + */ +export class SelectableListRemoveSelectionAction extends SelectableListAction { + constructor(id: string) { + super(SelectableListActionTypes.REMOVE_SELECTION, id); + } +} diff --git a/src/app/shared/object-list/selectable-list/selectable-list.service.ts b/src/app/shared/object-list/selectable-list/selectable-list.service.ts index b35cb204b71..79dde75ab74 100644 --- a/src/app/shared/object-list/selectable-list/selectable-list.service.ts +++ b/src/app/shared/object-list/selectable-list/selectable-list.service.ts @@ -23,6 +23,7 @@ import { SelectableListDeselectAction, SelectableListDeselectAllAction, SelectableListDeselectSingleAction, + SelectableListRemoveSelectionAction, SelectableListSelectAction, SelectableListSelectSingleAction, } from './selectable-list.actions'; @@ -49,6 +50,14 @@ export class SelectableListService { return this.store.pipe(select(menuByIDSelector(id))); } + /** + * Remove a selection list from state + * @param {string} id The id of the list to remove + */ + removeSelection(id: string) { + this.store.dispatch(new SelectableListRemoveSelectionAction(id)); + } + /** * Select an object in a specific list in the store * @param {string} id The id of the list on which the object should be selected diff --git a/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.html b/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.html new file mode 100644 index 00000000000..c1bd6baa164 --- /dev/null +++ b/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.html @@ -0,0 +1,20 @@ + + @if (item) { + + + } + @if (!item && entityType) { + + + } + @if (!item && !entityType) { + + + } + +@if (bulkExportLimit && bulkExportLimit !== '0' && bulkExportLimit !== '-1') { + + +} diff --git a/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.spec.ts b/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.spec.ts new file mode 100644 index 00000000000..5e7800e605f --- /dev/null +++ b/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.spec.ts @@ -0,0 +1,60 @@ +import { + Component, + Input, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { BrowserModule } from '@angular/platform-browser'; +import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; +import { ItemExportFormatMolteplicity } from '@dspace/core/itemexportformat/item-export-format.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { AlertComponent } from '../../../alert/alert.component'; +import { ItemExportAlertComponent } from './item-export-alert.component'; + +describe('ItemExportAlertComponent', () => { + let component: ItemExportAlertComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateLoaderMock } }), + ItemExportAlertComponent, + ], + providers: [DSONameService], + schemas: [ + NO_ERRORS_SCHEMA, + ], + }) + .overrideComponent(ItemExportAlertComponent, { remove: { imports: [AlertComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemExportAlertComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + +}); + +@Component({ selector: 'ds-item-export-alert', template: '' }) +export class ItemExportAlertStubComponent { + @Input() molteplicity: ItemExportFormatMolteplicity; + @Input() item: Item; + @Input() bulkExportLimit: string; +} diff --git a/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.ts b/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.ts new file mode 100644 index 00000000000..3c2fc3a7f90 --- /dev/null +++ b/src/app/shared/search/item-export/item-export-alert/item-export-alert.component.ts @@ -0,0 +1,33 @@ + +import { + Component, + Input, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { Item } from '../../../../core/shared/item.model'; +import { AlertComponent } from '../../../alert/alert.component'; + +@Component({ + selector: 'ds-item-export-alert', + templateUrl: './item-export-alert.component.html', + imports: [ + AlertComponent, + TranslateModule, + ], +}) +export class ItemExportAlertComponent { + + @Input() item: Item; + @Input() entityType: string; + @Input() bulkExportLimit: string; + + constructor(private dsoNameService: DSONameService) { + } + + getItemName() { + return this.dsoNameService.getName(this.item); + } + +} diff --git a/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.html b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.html new file mode 100644 index 00000000000..a71161bc3c6 --- /dev/null +++ b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.html @@ -0,0 +1,26 @@ + + @if (bulkExportLimit !== '0') { +
+ + +
+ } +
diff --git a/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.scss b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.scss new file mode 100644 index 00000000000..cc36f78bfa0 --- /dev/null +++ b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.scss @@ -0,0 +1,3 @@ +:host { + display: none; +} diff --git a/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.spec.ts b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.spec.ts new file mode 100644 index 00000000000..d9752294d18 --- /dev/null +++ b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.spec.ts @@ -0,0 +1,176 @@ +import { ViewContainerRef } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { ItemExportFormatMolteplicity } from '@dspace/core/itemexportformat/item-export-format.service'; +import { ItemType } from '@dspace/core/shared/item-relationships/item-type.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { EntityDropdownComponent } from '../../../entity-dropdown/entity-dropdown.component'; +import { ItemExportComponent } from '../item-export/item-export.component'; +import { ItemExportModalLauncherComponent } from './item-export-modal-launcher.component'; + +describe('ItemExportModalWrapperComponent', () => { + let component: ItemExportModalLauncherComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + + const modalService = jasmine.createSpyObj('modalService', ['open']); + + let authorizationService: AuthorizationDataService; + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: of(true), + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName'), + }); + + const authServiceMock: any = jasmine.createSpyObj('AuthService', { + isAuthenticated: jasmine.createSpy('isAuthenticated'), + }); + + const itemType = Object.assign(new ItemType(),{ + 'type': 'entitytype', + 'id': 1, + 'label': 'Person', + 'uuid': 'entitytype-1', + '_links': { + 'self': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/entitytypes/1', + }, + 'relationshiptypes': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/entitytypes/1/relationshiptypes', + }, + }, + }); + + const confResponseDisabled$ = createSuccessfulRemoteDataObject$({ values: ['0'] }); + const confResponseEnabled$ = createSuccessfulRemoteDataObject$({ values: ['100'] }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateLoaderMock } }), + ItemExportModalLauncherComponent, + ], + providers: [ + { provide: AuthService, useValue: authServiceMock }, + { provide: NgbModal, useValue: modalService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + ViewContainerRef, + ], + }).overrideComponent(ItemExportModalLauncherComponent, { remove: { imports: [EntityDropdownComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemExportModalLauncherComponent); + component = fixture.componentInstance; + componentAsAny = fixture.componentInstance; + }); + + // EXPORT LIMIT IS 0 + + describe('when export limit is 0', () => { + beforeEach(() => { + authServiceMock.isAuthenticated.and.returnValue(of(false)); + configurationDataService.findByPropertyName.and.returnValues(confResponseDisabled$); + }); + + it('should not display the open modal button', () => { + fixture.detectChanges(); + const btn = fixture.debugElement.query(By.css('.btn')); + expect(btn).toBeFalsy(); + }); + }); + + // EXPORT LIMIT IS NOT 0 + + describe('when export limit is not 0', () => { + + let btnDebugElement; + + beforeEach(() => { + authServiceMock.isAuthenticated.and.returnValue(of(true)); + configurationDataService.findByPropertyName.and.returnValues(confResponseEnabled$); + fixture.detectChanges(); + btnDebugElement = fixture.debugElement.query(By.css('.btn')); + }); + + it('should display the open modal button', () => { + expect(btnDebugElement).toBeTruthy(); + }); + + describe('and click on open modal', () => { + + let modalRef; + + beforeEach(() => { + modalRef = { componentInstance: {} }; + modalService.open.and.returnValue(modalRef); + spyOn(component, 'open').and.callThrough(); + }); + + it('should invoke component.open method', () => { + component.open(itemType); + fixture.detectChanges(); + btnDebugElement.triggerEventHandler('click', undefined); + expect(component.open).toHaveBeenCalled(); + }); + + describe('and an item is present', () => { + + beforeEach(() => { + component.item = 'item' as any; + }); + + it('should configure the ItemExportComponent with the item and molteplicity SINGLE', () => { + component.open(itemType); + expect(modalService.open).toHaveBeenCalledWith(ItemExportComponent, { size: 'xl' }); + expect(modalRef.componentInstance.item).toEqual('item'); + expect(modalRef.componentInstance.searchOptions).toBeFalsy(); + }); + + }); + + describe('and searchOptions$ are present', () => { + + beforeEach(() => { + component.searchOptions$ = of('searchOptions') as any; + }); + + it('should configure the ItemExportComponent with the searchOptions$ and molteplicity MULTIPLE', () => { + component.open(itemType); + expect(modalService.open).toHaveBeenCalledWith(ItemExportComponent, { size: 'xl' }); + expect(modalRef.componentInstance.item).toBeFalsy(); + expect(modalRef.componentInstance.searchOptions).toEqual('searchOptions'); + expect(modalRef.componentInstance.molteplicity).toEqual(ItemExportFormatMolteplicity.MULTIPLE); + expect(modalRef.componentInstance.showListSelection).toBeTrue(); + }); + + }); + + }); + + + }); + +}); diff --git a/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.ts b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.ts new file mode 100644 index 00000000000..e553928fc93 --- /dev/null +++ b/src/app/shared/search/item-export/item-export-modal-launcher/item-export-modal-launcher.component.ts @@ -0,0 +1,147 @@ +import { + Component, + Input, + OnInit, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { AuthService } from '@dspace/core/auth/auth.service'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; +import { ItemExportFormatMolteplicity } from '@dspace/core/itemexportformat/item-export-format.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { SearchOptions } from '@dspace/core/shared/search/models/search-options.model'; +import { isNotEmpty } from '@dspace/shared/utils/empty.util'; +import { + NgbDropdownModule, + NgbModal, + NgbModalOptions, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + combineLatest, + Observable, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { EntityDropdownComponent } from '../../../entity-dropdown/entity-dropdown.component'; +import { ItemExportComponent } from '../item-export/item-export.component'; + +export const BULK_EXPORT_LIMIT_ADMIN = 'bulk-export.limit.admin'; +export const BULK_EXPORT_LIMIT_LOGGEDIN = 'bulk-export.limit.loggedIn'; +export const BULK_EXPORT_LIMIT_NOTLOGGEDIN = 'bulk-export.limit.notLoggedIn'; + +@Component({ + selector: 'ds-item-export-modal-launcher', + styleUrls: ['./item-export-modal-launcher.component.scss'], + templateUrl: './item-export-modal-launcher.component.html', + imports: [ + EntityDropdownComponent, + NgbDropdownModule, + TranslateModule, + ], +}) +export class ItemExportModalLauncherComponent implements OnInit { + + @ViewChild('template', { static: true }) template; + + @Input() item: Item; + @Input() searchOptions$: Observable; + + bulkExportLimit = '0'; + + constructor(private modalService: NgbModal, + private authService: AuthService, + private authorizationService: AuthorizationDataService, + private configService: ConfigurationDataService, + private viewContainerRef: ViewContainerRef) { } + + ngOnInit() { + this.viewContainerRef.createEmbeddedView(this.template); + + combineLatest([this.isAuthenticated(), this.isCurrentUserAdmin()]).pipe( + take(1), + switchMap(([isAuthenticated, isAdmin]) => { + let propertyName ; + if (isAuthenticated) { + if (isAdmin) { + propertyName = BULK_EXPORT_LIMIT_ADMIN; + } else { + propertyName = BULK_EXPORT_LIMIT_LOGGEDIN; + } + } else { + propertyName = BULK_EXPORT_LIMIT_NOTLOGGEDIN; + } + + return this.findByPropertyName(propertyName); + }), + ).subscribe((bulkExportLimit: string) => { + this.bulkExportLimit = bulkExportLimit; + }); + } + + getLabel() { + return this.item ? 'Export' : 'Bulk Export'; + } + + open(event) { + const modalOptions: NgbModalOptions = { + size: 'xl', + }; + if (this.item) { + + // open a single item-export modal + const modalRef = this.modalService.open(ItemExportComponent, modalOptions); + modalRef.componentInstance.molteplicity = ItemExportFormatMolteplicity.SINGLE; + modalRef.componentInstance.item = this.item; + modalRef.componentInstance.itemType = event; + modalRef.componentInstance.bulkExportLimit = this.bulkExportLimit; + + } else if (this.searchOptions$) { + + // open a bulk-item-export modal + this.searchOptions$.pipe(take(1)).subscribe((searchOptions) => { + const modalRef = this.modalService.open(ItemExportComponent, modalOptions); + modalRef.componentInstance.molteplicity = ItemExportFormatMolteplicity.MULTIPLE; + modalRef.componentInstance.searchOptions = searchOptions; + modalRef.componentInstance.itemType = event; + modalRef.componentInstance.bulkExportLimit = this.bulkExportLimit; + modalRef.componentInstance.showListSelection = true; + }); + } + + } + + /** + * Return if the user is authenticated + */ + isAuthenticated(): Observable { + return this.authService.isAuthenticated(); + } + + /** + * Return if the user is admin + */ + isCurrentUserAdmin(): Observable { + return this.authorizationService.isAuthorized(FeatureID.AdministratorOf, undefined, undefined); + } + + /** + * it will fetch the export limit according to property + */ + findByPropertyName(property): Observable { + return this.configService.findByPropertyName(property).pipe( + getFirstCompletedRemoteData(), + map((res) => { + return (res.hasSucceeded && res.payload && isNotEmpty(res.payload.values)) ? res.payload.values[0] : '0'; + })); + } + +} + diff --git a/src/app/shared/search/item-export/item-export.service.spec.ts b/src/app/shared/search/item-export/item-export.service.spec.ts new file mode 100644 index 00000000000..5bdbf8ae843 --- /dev/null +++ b/src/app/shared/search/item-export/item-export.service.spec.ts @@ -0,0 +1,146 @@ +import { of } from 'rxjs'; + +import { + ItemExportFormatMolteplicity, + ItemExportFormatService, +} from '../../../core/itemexportformat/item-export-format.service'; +import { + ItemExportFormat, + ItemExportFormatMap, +} from '../../../core/itemexportformat/model/item-export-format.model'; +import { Item } from '../../../core/shared/item.model'; +import { ItemExportService } from './item-export.service'; + + +const ThePublication = Object.assign(new Item(), { + uuid: 'ThePublicationUUID', + metadata: { + 'dc.title': [ { value: 'A Title' }], + 'dspace.entity.type': [ { value: 'Publication' }], + }, +}); + +export const ItemExportFormatsMap: ItemExportFormatMap = { + 'Publication': [ + Object.assign(new ItemExportFormat(), { id: 'publication-xml', entityType: 'Publication' }), + Object.assign(new ItemExportFormat(), { id: 'publication-json', entityType: 'Publication' }), + ], + 'Project': [ + Object.assign(new ItemExportFormat(), { id: 'project-xml', entityType: 'Project' }), + ], +}; + +describe('ItemExportService', () => { + + let service: ItemExportService; + let itemExportFormatService: ItemExportFormatService; + + beforeEach(() => { + itemExportFormatService = jasmine.createSpyObj('itemExportFormatService', + ['byEntityTypeAndMolteplicity', 'doExport', 'doExportMulti']); + service = new ItemExportService(itemExportFormatService); + }); + + describe('initialItemExportFormConfiguration', () => { + + beforeEach(() => { + (itemExportFormatService.byEntityTypeAndMolteplicity as any).and.returnValue(of(ItemExportFormatsMap)); + }); + + describe('when an item is passed', () => { + it('should return the export single configuration', (done) => { + const expectedEntityType = 'Publication'; + const expectedFormats = ItemExportFormatsMap[expectedEntityType]; + + service.initialItemExportFormConfiguration(ThePublication).subscribe((configuration) => { + expect(configuration.entityTypes).toEqual(null); + expect(configuration.entityType).toEqual(expectedEntityType); + expect(configuration.formats).toEqual(expectedFormats); + expect(configuration.format).toEqual(expectedFormats[0]); + done(); + }); + + }); + }); + + describe('when no item are passed ', () => { + it('should return the export multiple configuration', (done) => { + + const expectedEntityTypes = Object.keys(ItemExportFormatsMap); + + service.initialItemExportFormConfiguration(null).subscribe((configuration) => { + expect(configuration.entityTypes).toEqual(expectedEntityTypes); + expect(configuration.entityType).toEqual(null); + expect(configuration.formats).toEqual([]); + expect(configuration.format).toEqual(null); + done(); + }); + + }); + }); + + }); + + describe('onSelectEntityType', () => { + + beforeEach(() => { + (itemExportFormatService.byEntityTypeAndMolteplicity as any).and.returnValue(of(ItemExportFormatsMap)); + }); + + + it('should return the export multiple configuration with the entityType selected', (done) => { + + const availableEntityTypes = Object.keys(ItemExportFormatsMap); + const selectedEntityType = 'Project'; + const expectedFormats = ItemExportFormatsMap[selectedEntityType]; + + service.onSelectEntityType(availableEntityTypes, selectedEntityType).subscribe((configuration) => { + expect(configuration.entityTypes).toEqual(availableEntityTypes); + expect(configuration.entityType).toEqual(selectedEntityType); + expect(configuration.formats).toEqual(expectedFormats); + expect(configuration.format).toEqual(expectedFormats[0]); + done(); + }); + + }); + }); + + describe('submitForm', () => { + + beforeEach(() => { + (itemExportFormatService.doExport as any).and.returnValue(of(1111)); + (itemExportFormatService.doExportMulti as any).and.returnValue(of(2222)); + }); + + describe('when single export', () => { + it('should invoke doExport and return the processNumber', (done) => { + const selectedFormat = ItemExportFormatsMap.Publication[0]; + + service.submitForm(ItemExportFormatMolteplicity.SINGLE, ThePublication, null, null, selectedFormat).subscribe((processNumber) => { + expect(itemExportFormatService.doExport).toHaveBeenCalledWith('ThePublicationUUID', selectedFormat); + expect(itemExportFormatService.doExportMulti).not.toHaveBeenCalled(); + expect(processNumber).toEqual(1111); + done(); + }); + }); + }); + + describe('when bulk export', () => { + it('should invoke doExportMulti and return the processNumber', (done) => { + const selectedFormat = ItemExportFormatsMap.Publication[0]; + const selectedEntityType = 'Publication'; + const searchOptions: any = 'searchOptions'; + + service.submitForm(ItemExportFormatMolteplicity.MULTIPLE, null, searchOptions, selectedEntityType, selectedFormat).subscribe((processNumber) => { + expect(itemExportFormatService.doExport).not.toHaveBeenCalled(); + expect(itemExportFormatService.doExportMulti).toHaveBeenCalledWith(selectedEntityType, selectedFormat, searchOptions, []); + expect(processNumber).toEqual(2222); + done(); + }); + + }); + }); + + }); + +}); diff --git a/src/app/shared/search/item-export/item-export.service.ts b/src/app/shared/search/item-export/item-export.service.ts new file mode 100644 index 00000000000..0cee41fc07d --- /dev/null +++ b/src/app/shared/search/item-export/item-export.service.ts @@ -0,0 +1,130 @@ +import { Injectable } from '@angular/core'; +import { + ItemExportFormatMolteplicity, + ItemExportFormatService, +} from '@dspace/core/itemexportformat/item-export-format.service'; +import { ItemExportFormat } from '@dspace/core/itemexportformat/model/item-export-format.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { SearchOptions } from '@dspace/core/shared/search/models/search-options.model'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +/** + * Configuration for the item export form, including available entity types and formats. + */ +export interface ItemExportFormConfiguration { + entityType: string; + format: ItemExportFormat; + + entityTypes: string[]; + formats: ItemExportFormat[]; +} + +/** + * Service responsible for managing item export operations. + * Provides methods to initialize export form configurations, handle entity type changes, + * and submit export requests for single or multiple items. + */ +@Injectable({ + providedIn: 'root', +}) +export class ItemExportService { + + constructor(private itemExportFormatService: ItemExportFormatService) { + } + + /** + * Initialize the item export form configuration. + * @param item + */ + public initialItemExportFormConfiguration(item: Item): Observable { + if (item) { + return this.initialItemExportFormConfigurationSingle(item); + } + return this.initialItemExportFormConfigurationMultiple(); + } + + /** + * A new item export form configuration when a specific entityType is selected. + * @param entityTypes + * @param entityType + */ + public onSelectEntityType(entityTypes: string[], entityType): Observable { + return this.itemExportFormatService.byEntityTypeAndMolteplicity(entityType, ItemExportFormatMolteplicity.MULTIPLE).pipe( + take(1), + map(values => this.buildConfiguration(entityTypes, entityType, values[entityType])), + ); + } + + /** + * Perform the export operation. + * @param molteplicity + * @param item + * @param searchOptions + * @param entityType + * @param format + * @param itemList + */ + public submitForm( + molteplicity: ItemExportFormatMolteplicity, + item: Item, + searchOptions: SearchOptions, + entityType: string, + format: ItemExportFormat, + itemList: string[] = [], + ): Observable { + if (molteplicity === ItemExportFormatMolteplicity.SINGLE) { + return this.itemExportFormatService.doExport(item.uuid, format); + } else { + return this.itemExportFormatService.doExportMulti(entityType, format, searchOptions, itemList); + } + } + + /** + * Initialize export form configuration for a single item export. + * Resolves export formats based on the item's entity type. + * @param item - The item to export + * @returns Observable emitting the form configuration for single-item export + */ + protected initialItemExportFormConfigurationSingle(item: Item): Observable { + const entityType = item.firstMetadataValue('dspace.entity.type') || 'none'; + + return this.itemExportFormatService.byEntityTypeAndMolteplicity(entityType, ItemExportFormatMolteplicity.SINGLE).pipe( + take(1), + map(values => this.buildConfiguration(null, entityType, values[entityType])), + ); + } + + /** + * Initialize export form configuration for a multiple/bulk item export. + * Resolves all available entity types and their formats. + * @returns Observable emitting the form configuration for bulk export + */ + protected initialItemExportFormConfigurationMultiple(): Observable { + return this.itemExportFormatService.byEntityTypeAndMolteplicity(null, ItemExportFormatMolteplicity.MULTIPLE).pipe( + take(1), + map(values => this.buildConfiguration(Object.keys(values), null, [])), + ); + } + + /** + * Build an ItemExportFormConfiguration from the given parameters. + * @param entityTypes - Available entity types (for bulk export selection) + * @param entityType - The currently selected entity type + * @param formats - The available export formats for the selected entity type + * @returns The constructed form configuration + */ + protected buildConfiguration(entityTypes: string[], entityType: string, formats: ItemExportFormat[]): ItemExportFormConfiguration { + const _formats = formats ? formats : []; + return { + entityType, + format: _formats.length > 0 ? _formats[0] : null, + entityTypes: entityTypes, + formats: _formats, + }; + } + +} diff --git a/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.html b/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.html new file mode 100644 index 00000000000..5b5acf8c3ed --- /dev/null +++ b/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.html @@ -0,0 +1,27 @@ +
+ + @if (resultsRD?.hasSucceeded && !resultsRD?.isLoading && resultsRD?.payload?.page?.length > 0) { + + + } + @if (!resultsRD || resultsRD?.isLoading) { + + } + @if (resultsRD?.hasFailed && (!resultsRD?.errorMessage || resultsRD?.statusCode !== 400)) { + + } + @if (resultsRD?.payload?.page?.length === 0 || resultsRD?.statusCode === 400) { +
+ {{ 'search.results.empty' | translate }} +
+ } +
+
diff --git a/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.scss b/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.spec.ts b/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.spec.ts new file mode 100644 index 00000000000..6fa41dbb890 --- /dev/null +++ b/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.spec.ts @@ -0,0 +1,93 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { PaginationService } from '@dspace/core/pagination/pagination.service'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; +import { SearchOptions } from '@dspace/core/shared/search/models/search-options.model'; +import { PaginationServiceStub } from '@dspace/core/testing/pagination-service.stub'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '@dspace/core/utilities/remote-data.utils'; + +import { ErrorComponent } from '../../../../error/error.component'; +import { ThemedLoadingComponent } from '../../../../loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../object-collection/object-collection.component'; +import { ItemExportListComponent } from './item-export-list.component'; + +describe('ItemExportListComponent', () => { + let component: ItemExportListComponent; + let fixture: ComponentFixture; + + const paginationService = new PaginationServiceStub(); + const mockDso = Object.assign(new Item(), { + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Item nr 1', + }, + ], + }, + _links: { + self: { + href: 'selfLink1', + }, + }, + }); + + const mockDso2 = Object.assign(new Item(), { + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Item nr 2', + }, + ], + }, + _links: { + self: { + href: 'selfLink2', + }, + }, + }); + const mockSearchResults: SearchObjects = Object.assign(new SearchObjects(), { + page: [mockDso, mockDso2], + }); + const mockSearchResultsRD = createSuccessfulRemoteDataObject(mockSearchResults); + + const mockSearchManager = jasmine.createSpyObj('SearchManager', { + search: jasmine.createSpy('search'), + }); + + const searchOptions = new SearchOptions({}); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ItemExportListComponent, NoopAnimationsModule], + providers: [ + { provide: PaginationService, useValue: paginationService }, + { provide: SearchManager, useValue: mockSearchManager }, + ], + }) + .overrideComponent(ItemExportListComponent, { remove: { imports: [ObjectCollectionComponent, ThemedLoadingComponent, ErrorComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemExportListComponent); + component = fixture.componentInstance; + component.searchOptions = searchOptions; + mockSearchManager.search.and.returnValue(createSuccessfulRemoteDataObject$(mockSearchResults)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.resultsRD$.value).toEqual(mockSearchResultsRD); + }); +}); diff --git a/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.ts b/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.ts new file mode 100644 index 00000000000..771ab1d8be5 --- /dev/null +++ b/src/app/shared/search/item-export/item-export/item-export-list/item-export-list.component.ts @@ -0,0 +1,112 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { PaginationService } from '@dspace/core/pagination/pagination.service'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { Context } from '@dspace/core/shared/context.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; +import { SearchOptions } from '@dspace/core/shared/search/models/search-options.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; + +import { SEARCH_CONFIG_SERVICE } from '../../../../../my-dspace-page/my-dspace-configuration.service'; +import { fadeIn } from '../../../../animations/fade'; +import { ErrorComponent } from '../../../../error/error.component'; +import { ThemedLoadingComponent } from '../../../../loading/themed-loading.component'; +import { ObjectCollectionComponent } from '../../../../object-collection/object-collection.component'; +import { VarDirective } from '../../../../utils/var.directive'; +import { SearchConfigurationService } from '../../../search-configuration.service'; + +@Component({ + selector: 'ds-item-export-list', + templateUrl: './item-export-list.component.html', + styleUrls: ['./item-export-list.component.scss'], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService, + }, + ], + animations: [fadeIn], + imports: [ + AsyncPipe, + ErrorComponent, + ObjectCollectionComponent, + ThemedLoadingComponent, + TranslateModule, + VarDirective, + ], +}) +export class ItemExportListComponent implements OnInit { + + @Input() itemEntityType: string; + @Input() listId: string; + + @Input() searchOptions: SearchOptions; + + /** + * The configuration to use for the search options + */ + configuration: string; + + /** + * The current context + * If empty, 'search' is used + */ + context: Context = Context.Search; + + /** + * The current pagination options + */ + currentPagination$: Observable; + + /** + * The initial pagination options + */ + initialPagination: PaginationComponentOptions; + + /** + * The displayed list of entries + */ + resultsRD$: BehaviorSubject>> = new BehaviorSubject(null); + + constructor( + private paginationService: PaginationService, + private searchManager: SearchManager) { + } + + ngOnInit(): void { + this.initialPagination = Object.assign(new PaginationComponentOptions(), { + id: 'el' + this.listId, + pageSize: 10, + }); + this.configuration = this.searchOptions.configuration; + this.currentPagination$ = this.paginationService.getCurrentPagination(this.initialPagination.id, this.initialPagination); + this.currentPagination$.subscribe((paginationOptions: PaginationComponentOptions) => { + this.searchOptions = Object.assign(new PaginatedSearchOptions({}), this.searchOptions, { + fixedFilter: `f.entityType=${this.itemEntityType},equals`, + pagination: paginationOptions, + }); + this.retrieveResultList(this.searchOptions); + }); + } + + retrieveResultList(searchOptions: PaginatedSearchOptions): void { + this.resultsRD$.next(null); + this.searchManager.search(searchOptions).pipe(getFirstCompletedRemoteData()) + .subscribe((results: RemoteData>) => { + this.resultsRD$.next(results); + }); + } +} diff --git a/src/app/shared/search/item-export/item-export/item-export.component.html b/src/app/shared/search/item-export/item-export/item-export.component.html new file mode 100644 index 00000000000..c6ddf36b5c8 --- /dev/null +++ b/src/app/shared/search/item-export/item-export/item-export.component.html @@ -0,0 +1,115 @@ + + + diff --git a/src/app/shared/search/item-export/item-export/item-export.component.spec.ts b/src/app/shared/search/item-export/item-export/item-export.component.spec.ts new file mode 100644 index 00000000000..7dd17dc58ab --- /dev/null +++ b/src/app/shared/search/item-export/item-export/item-export.component.spec.ts @@ -0,0 +1,422 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { ItemType } from '@dspace/core/shared/item-relationships/item-type.model'; +import { PageInfo } from '@dspace/core/shared/page-info.model'; +import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; +import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; +import { NotificationsServiceStub } from '@dspace/core/testing/notifications-service.stub'; +import { RouterMock } from '@dspace/core/testing/router.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { SelectableListService } from 'src/app/shared/object-list/selectable-list/selectable-list.service'; + +import { AlertComponent } from '../../../alert/alert.component'; +import { AdministeredCollectionSelectorComponent } from '../../../dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component'; +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +import { + ItemExportFormConfiguration, + ItemExportService, +} from '../item-export.service'; +import { ItemExportAlertComponent } from '../item-export-alert/item-export-alert.component'; +import { ItemExportAlertStubComponent } from '../item-export-alert/item-export-alert.component.spec'; +import { + ExportSelectionMode, + ItemExportComponent, +} from './item-export.component'; +import { ItemExportListComponent } from './item-export-list/item-export-list.component'; + +describe('ItemExportComponent', () => { + let component: ItemExportComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + let configuration: ItemExportFormConfiguration; + let exportForm: FormGroup; + + const itemExportService: any = jasmine.createSpyObj('ItemExportFormatService', { + initialItemExportFormConfiguration: jasmine.createSpy('initialItemExportFormConfiguration'), + onSelectEntityType: jasmine.createSpy('onSelectEntityType'), + submitForm: jasmine.createSpy('submitForm'), + }); + + const modal: any = jasmine.createSpyObj('NgbActiveModal', { + close: jasmine.createSpy('close').and.callFake(() => { /**/ + }), + }); + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + uuid: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + entityType: 'Person', + _links: { + self: { + href: 'https://localhost:8000/items/fake-id', + }, + }, + }); + + const router = new RouterMock(); + + const itemType = Object.assign(new ItemType(), { + 'type': 'entitytype', + 'id': 1, + 'label': 'Person', + 'uuid': 'entitytype-1', + '_links': { + 'self': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/entitytypes/1', + }, + 'relationshiptypes': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/entitytypes/1/relationshiptypes', + }, + }, + }); + + const mockSearchResults: SearchObjects = Object.assign(new SearchObjects(), { + page: [mockItem], + pageInfo: Object.assign(new PageInfo(), { + totalElements: 10, + }), + }); + + const mockEmptySearchResults: SearchObjects = Object.assign(new SearchObjects(), { + page: [], + pageInfo: Object.assign(new PageInfo(), { + totalElements: 0, + }), + }); + + + const mockSearchManager = jasmine.createSpyObj('SearchManager', { + search: jasmine.createSpy('search'), + }); + + const selectService = jasmine.createSpyObj('selectService', { + getSelectableList: jasmine.createSpy('getSelectableList'), + removeSelection: jasmine.createSpy('removeSelection'), + }); + + const firstSearchResult = Object.assign(new SearchResult(), { + indexableObject: Object.assign(new DSpaceObject(), { + id: 'd317835d-7b06-4219-91e2-1191900cb897', + uuid: 'd317835d-7b06-4219-91e2-1191900cb897', + name: 'My first publication', + metadata: { + 'dspace.entity.type': [ + { value: 'Publication' }, + ], + }, + }), + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateLoaderMock } }), + FormsModule, + ReactiveFormsModule, + ItemExportComponent, + ItemExportAlertStubComponent, + ], + providers: [ + { provide: ItemExportService, useValue: itemExportService }, + { provide: NgbActiveModal, useValue: modal }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: SelectableListService, useValue: selectService }, + { provide: Router, useValue: router }, + { provide: SearchManager, useValue: mockSearchManager }, + ], + schemas: [ + NO_ERRORS_SCHEMA, + ], + }) + .overrideComponent(ItemExportComponent, { remove: { imports: [ThemedLoadingComponent, ItemExportListComponent, ItemExportAlertComponent, AdministeredCollectionSelectorComponent, AlertComponent] } }).compileComponents(); + })); + + describe('when cannot export', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ItemExportComponent); + component = fixture.componentInstance; + componentAsAny = fixture.componentInstance; + + // inputs + component.searchOptions = 'searchOptions' as any; + component.molteplicity = 'molteplicity' as any; + + // component.itemType = itemType; + component.bulkExportLimit = '-1' as any; + + // data + configuration = { format: 'format', entityType: 'entityType', entityTypes: ['entityType'] } as any; + + component.showListSelection = true; + component.itemType = itemType; + component.item = undefined; + // spies + itemExportService.initialItemExportFormConfiguration.calls.reset(); + mockSearchManager.search.and.returnValue(createSuccessfulRemoteDataObject$(mockEmptySearchResults)); + fixture.detectChanges(); + }); + + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show a warning alert', () => { + expect(itemExportService.initialItemExportFormConfiguration).not.toHaveBeenCalled(); + + const alert = fixture.debugElement.query(By.css('[data-test="cannotExport"]')); + expect(alert).toBeTruthy(); + }); + + }); + + describe('when can export', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ItemExportComponent); + component = fixture.componentInstance; + componentAsAny = fixture.componentInstance; + + // inputs + component.searchOptions = 'searchOptions' as any; + component.molteplicity = 'molteplicity' as any; + + // component.itemType = itemType; + component.bulkExportLimit = '-1' as any; + + // data + configuration = { format: 'format', entityType: 'entityType', entityTypes: ['entityType'] } as any; + + // spies + itemExportService.initialItemExportFormConfiguration.and.returnValue(of(configuration)); + mockSearchManager.search.and.returnValue(createSuccessfulRemoteDataObject$(mockSearchResults)); + + }); + + describe('when item is given', () => { + beforeEach(() => { + component.item = mockItem; + component.showListSelection = false; + exportForm = new FormGroup({ + format: new FormControl(configuration.format, [Validators.required]), + entityType: new FormControl(configuration.entityType, [Validators.required]), + }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the exportForm calling initialItemExportFormConfiguration', () => { + expect(itemExportService.initialItemExportFormConfiguration).toHaveBeenCalledWith(mockItem); + expect(component.configuration).toBe(configuration); + // expect(component.exportForm).toEqual(exportForm); + const formatSelect = fixture.debugElement.query(By.css('[data-test="format-select"]')); + const selectionRadio = fixture.debugElement.query(By.css('[data-test="selection-radio"]')); + expect(formatSelect).toBeTruthy(); + expect(selectionRadio).toBeNull(); + }); + + }); + + describe('when item is not given and itemType is given', () => { + + beforeEach(() => { + component.item = null; + component.itemType = itemType; + itemExportService.onSelectEntityType.and.returnValue(of(configuration)); + }); + + describe('when showListSelection is false', () => { + + beforeEach(() => { + component.showListSelection = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the exportForm calling initialItemExportFormConfiguration', () => { + expect(itemExportService.initialItemExportFormConfiguration).toHaveBeenCalledWith(null); + expect(component.configuration).toBe(configuration); + const formatSelect = fixture.debugElement.query(By.css('[data-test="format-select"]')); + const selectionRadio = fixture.debugElement.query(By.css('[data-test="selection-radio"]')); + expect(formatSelect).toBeTruthy(); + expect(selectionRadio).toBeNull(); + }); + }); + + describe('when showListSelection is true', () => { + + beforeEach(() => { + component.showListSelection = true; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the exportForm calling initialItemExportFormConfiguration', () => { + expect(itemExportService.initialItemExportFormConfiguration).toHaveBeenCalledWith(null); + expect(component.configuration).toBe(configuration); + const formatSelect = fixture.debugElement.query(By.css('[data-test="format-select"]')); + const selectionRadio = fixture.debugElement.query(By.css('[data-test="selection-radio"]')); + expect(formatSelect).toBeTruthy(); + expect(selectionRadio).toBeTruthy(); + }); + }); + + }); + + describe('when both item and itemType are not given', () => { + beforeEach(() => { + component.item = null; + component.itemType = null; + itemExportService.onSelectEntityType.and.returnValue(of(configuration)); + }); + + describe('when showListSelection is false', () => { + + beforeEach(() => { + component.showListSelection = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the exportForm calling initialItemExportFormConfiguration', () => { + expect(itemExportService.initialItemExportFormConfiguration).toHaveBeenCalledWith(null); + expect(component.configuration).toBe(configuration); + // expect(component.exportForm).toEqual(exportForm); + const formatSelect = fixture.debugElement.query(By.css('[data-test="format-select"]')); + const selectionRadio = fixture.debugElement.query(By.css('[data-test="selection-radio"]')); + expect(formatSelect).toBeTruthy(); + expect(selectionRadio).toBeNull(); + }); + }); + + describe('when showListSelection is true', () => { + + beforeEach(() => { + component.showListSelection = true; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the exportForm calling initialItemExportFormConfiguration', () => { + expect(itemExportService.initialItemExportFormConfiguration).toHaveBeenCalledWith(null); + expect(component.configuration).toBe(configuration); + // expect(component.exportForm).toEqual(exportForm); + const formatSelect = fixture.debugElement.query(By.css('[data-test="format-select"]')); + const selectionRadio = fixture.debugElement.query(By.css('[data-test="selection-radio"]')); + expect(formatSelect).toBeTruthy(); + expect(selectionRadio).toBeTruthy(); + }); + }); + + }); + + }); + + describe('onSubmit method', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ItemExportComponent); + component = fixture.componentInstance; + + // component status + component.item = 'item' as any; + component.searchOptions = 'searchOptions' as any; + component.molteplicity = 'molteplicity' as any; + component.itemType = itemType; + component.bulkExportLimit = '-1' as any; + component.showListSelection = false; + component.exportForm = new FormGroup({ + format: new FormControl('format', [Validators.required]), + entityType: new FormControl('Person', [Validators.required]), + selectionMode: new FormControl(ExportSelectionMode.OnlySelection, [Validators.required]), + }); + + // spies + itemExportService.submitForm.and.returnValue(of('processNumber')); + mockSearchManager.search.and.returnValue(createSuccessfulRemoteDataObject$(mockSearchResults)); + }); + + + describe('when has no selection', () => { + beforeEach(() => { + const selection = { + selection: [], + }; + selectService.getSelectableList.and.returnValue(of(selection)); + }); + + it('should call the submitForm and then route to process number and close modal', () => { + component.onSubmit(); + + expect(itemExportService.submitForm).toHaveBeenCalledWith('molteplicity', 'item', 'searchOptions', 'Person', 'format', []); + expect((component as any).notificationsService.process).toHaveBeenCalled(); + expect(component.activeModal.close).toHaveBeenCalled(); + }); + + }); + + describe('when has selection', () => { + beforeEach(() => { + component.showListSelection = true; + const selection = { + selection: [firstSearchResult], + }; + selectService.getSelectableList.and.returnValue(of(selection)); + }); + + it('should call the submitForm and then route to process number and close modal', () => { + component.onSubmit(); + + expect(itemExportService.submitForm).toHaveBeenCalledWith('molteplicity', 'item', 'searchOptions', 'Person', 'format', ['d317835d-7b06-4219-91e2-1191900cb897']); + expect((component as any).notificationsService.process).toHaveBeenCalled(); + expect(component.activeModal.close).toHaveBeenCalled(); + }); + + }); + }); + + +}); diff --git a/src/app/shared/search/item-export/item-export/item-export.component.ts b/src/app/shared/search/item-export/item-export/item-export.component.ts new file mode 100644 index 00000000000..95fee87e0d7 --- /dev/null +++ b/src/app/shared/search/item-export/item-export/item-export.component.ts @@ -0,0 +1,331 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { ItemExportFormatMolteplicity } from '@dspace/core/itemexportformat/item-export-format.service'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { DSpaceObjectType } from '@dspace/core/shared/dspace-object-type.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { ItemType } from '@dspace/core/shared/item-relationships/item-type.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; +import { SearchOptions } from '@dspace/core/shared/search/models/search-options.model'; +import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; +import { + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + of, +} from 'rxjs'; +import { + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; +import { BtnDisabledDirective } from 'src/app/shared/btn-disabled.directive'; + +import { MYDSPACE_ROUTE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { AlertComponent } from '../../../alert/alert.component'; +import { AdministeredCollectionSelectorComponent } from '../../../dso-selector/dso-selector/administered-collection-selector/administered-collection-selector.component'; +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +import { SelectableListState } from '../../../object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; +import { + ItemExportFormConfiguration, + ItemExportService, +} from '../item-export.service'; +import { ItemExportAlertComponent } from '../item-export-alert/item-export-alert.component'; +import { ItemExportListComponent } from './item-export-list/item-export-list.component'; + +export enum ExportSelectionMode { + All = 'all', + OnlySelection = 'onlySelection' +} + +@Component({ + selector: 'ds-item-export', + templateUrl: './item-export.component.html', + imports: [ + AdministeredCollectionSelectorComponent, + AlertComponent, + AsyncPipe, + BtnDisabledDirective, + FormsModule, + ItemExportAlertComponent, + ItemExportListComponent, + ReactiveFormsModule, + ThemedLoadingComponent, + TranslateModule, + ], +}) +export class ItemExportComponent implements OnInit, OnDestroy { + + /** + * Export format suitable for bulk import + */ + public static BULK_IMPORT_READY_XLS = 'collection-xls'; + + @Input() molteplicity: ItemExportFormatMolteplicity; + @Input() item: Item; + @Input() searchOptions: SearchOptions; + @Input() itemType: ItemType; + @Input() bulkExportLimit: string; + @Input() showListSelection: boolean; + + public configuration: ItemExportFormConfiguration; + public exportForm: FormGroup; + + /** + * When true, show collection selector + */ + selectCollection = false; + + /** + * The selected entity type. + * This is used by ds-administered-collection-selector when the "bulk import ready" format has been chosen. + */ + selectedEntityType: string; + + /** + * The UUID of the selected collection. This is needed by the "bulk import ready" format. + */ + bulkImportXlsEntityTypeCollectionUUID: string; + + /** + * This is used by ds-administered-collection-selector when the "bulk import ready" format has been chosen. + */ + bulkImportXlsCollectionSelector = [DSpaceObjectType.COLLECTION]; + + /** + * When true, export configurations have been loaded and the select field can be shown. + */ + configurationLoaded$: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if there are objects to export according to the given configuration + */ + canExport$: BehaviorSubject = new BehaviorSubject(false); + + currentUrl: string; + + /** + * Contains the export selection mode value + */ + exportSelectionMode: BehaviorSubject = new BehaviorSubject(ExportSelectionMode.All); + + /** + * A boolean representing if component is initialized + */ + initialized$: BehaviorSubject = new BehaviorSubject(false); + + listId = 'export-list'; + + constructor( + protected itemExportService: ItemExportService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + public activeModal: NgbActiveModal, + private selectableListService: SelectableListService, + private searchManager: SearchManager) { + } + + ngOnInit() { + + this.currentUrl = this.router.url; + if (isEmpty(this.showListSelection)) { + this.showListSelection = !this.currentUrl.includes(MYDSPACE_ROUTE); + } + + let init$; + if (isEmpty(this.item) && isNotEmpty(this.itemType)) { + init$ = this.canExport().pipe( + take(1), + tap((canExport) => { + if (!canExport) { + this.canExport$.next(false); + this.configurationLoaded$.next(true); + this.initialized$.next(true); + } + }), + filter((canExport) => canExport), + switchMap(() => { + return this.itemExportService.initialItemExportFormConfiguration(this.item).pipe(take(1)); + }), + ); + } else { + init$ = this.itemExportService.initialItemExportFormConfiguration(this.item).pipe(take(1)); + } + + init$.subscribe((configuration: ItemExportFormConfiguration) => { + this.configuration = configuration; + this.canExport$.next(true); + this.configurationLoaded$.next(true); + this.initialized$.next(true); + + if (this.item) { + this.exportForm = this.initForm(configuration); + } else if (this.itemType) { + this.exportForm = this.initForm(configuration, true); + this.onEntityTypeChange(this.itemType.label); + if (this.showListSelection) { + this.exportForm.controls.selectionMode.valueChanges.subscribe((selectionMode) => { + this.showSelectionList(selectionMode); + }); + } + } else { + this.exportForm = this.initForm(configuration); + this.onEntityTypeChange(configuration.entityType); + // listen for entityType selections in order to update the available formats + this.exportForm.controls.entityType.valueChanges.subscribe((entityType) => { + this.onEntityTypeChange(entityType); + }); + if (this.showListSelection) { + this.exportForm.controls.selectionMode.valueChanges.subscribe((selectionMode) => { + this.showSelectionList(selectionMode); + }); + } + } + }); + } + + onEntityTypeChange(entityType: string) { + this.configurationLoaded$.next(false); + this.itemExportService.onSelectEntityType(this.configuration.entityTypes, entityType).pipe(take(1)).subscribe((configuration) => { + this.configuration = configuration; + this.selectedEntityType = entityType; + this.exportForm.controls.format.patchValue(this.configuration.format); + + this.configurationLoaded$.next(true); + }); + } + + initForm(configuration: ItemExportFormConfiguration, fromItemType = false): FormGroup { + const formGroup = new UntypedFormGroup({ + format: new FormControl(configuration.format, [Validators.required]), + + }); + if (fromItemType) { + formGroup.addControl('entityType', new UntypedFormControl({ value: this.itemType.label, disabled: true }, [Validators.required])); + } else { + formGroup.addControl('entityType', new UntypedFormControl(configuration.entityType, [Validators.required])); + } + if (this.showListSelection) { + formGroup.addControl('selectionMode', new UntypedFormControl(this.exportSelectionMode.value, [Validators.required])); + } + return formGroup; + } + + isFormValid(): Observable { + if (this.exportForm?.value?.selectionMode === ExportSelectionMode.OnlySelection) { + return this.selectableListService.getSelectableList(this.listId).pipe( + map((list: SelectableListState) => list?.selection?.length > 0), + ); + } else { + return of(this.exportForm.valid); + } + } + + onCollectionSelect(collection) { + this.bulkImportXlsEntityTypeCollectionUUID = collection.uuid; + this.selectCollection = true; + } + + + onSubmit() { + if (this.exportForm.valid) { + if (!!this.exportForm.value.format.id && this.exportForm.value.format.id === ItemExportComponent.BULK_IMPORT_READY_XLS && !this.selectCollection) { + // if the "bulk import" format has been chosen, show the collection selection form + this.selectCollection = true; + } else { + // select the collection and submit + if (isNotEmpty(this.bulkImportXlsEntityTypeCollectionUUID)) { + this.searchOptions = Object.assign(new SearchOptions({}), this.searchOptions, { + query: `location.coll:${this.bulkImportXlsEntityTypeCollectionUUID}`, + scope: this.bulkImportXlsEntityTypeCollectionUUID, + }); + } + + const list$: Observable = (!this.showListSelection || this.exportForm?.value?.selectionMode !== ExportSelectionMode.OnlySelection) ? + of([]) : + this.selectableListService.getSelectableList(this.listId).pipe( + take(1), + map((list: SelectableListState) => (list?.selection || []).map((entry: SearchResult) => entry?.indexableObject?.id)), + ); + list$.pipe( + switchMap((list: string[]) => { + return this.itemExportService.submitForm( + this.molteplicity, + this.item, + this.searchOptions, + this.itemType ? this.exportForm.controls.entityType.value : this.exportForm.value.entityType, + this.exportForm.value.format, + list, + ); + }), + ).pipe(take(1)).subscribe((processId) => { + const title = this.translate.get('item-export.process.title'); + this.notificationsService.process(processId.toString(), 5000, title); + + this.selectCollection = false; + this.selectedEntityType = undefined; + this.bulkImportXlsEntityTypeCollectionUUID = undefined; + + this.activeModal.close(); + }); + } + } + } + + private canExport(): Observable { + return this.searchManager.search( + Object.assign(new PaginatedSearchOptions({}), this.searchOptions, { + fixedFilter: `f.entityType=${this.itemType.label},equals`, + pagination: Object.assign(new PaginationComponentOptions(), { + id: 'ex' + this.item?.id, + pageSize: 1, + }), + }), + ).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData>) => rd?.payload?.totalElements > 0), + ); + } + + private showSelectionList(selectionMode: any) { + this.exportSelectionMode.next(selectionMode); + } + + ngOnDestroy(): void { + this.router.navigateByUrl(this.currentUrl); + this.selectableListService.removeSelection(this.listId); + } +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 6551143ff13..bb9969e9bb6 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3117,6 +3117,8 @@ "item.page.edit": "Administer", + "item.page.export": "Export", + "item.page.files": "Files", "item.page.filesection.description": "Description:", @@ -3452,6 +3454,20 @@ "item.version.edit.notification.failure": "The summary of version number {{version}} has not been changed", + "item-export.alert.single": "Export item {{title}}", + + "item-export.form.format": "Format", + + "item-export.form.format.tip": "Choose the export format", + + "item-export.modal.title": "Item Export Process Launcher", + + "item-export.form.btn.submit": "Export", + + "item-export.form.btn.cancel": "Cancel", + + "item-export.process.title": "Bulk export item", + "itemtemplate.edit.metadata.add-button": "Add", "itemtemplate.edit.metadata.discard-button": "Discard", @@ -4268,6 +4284,14 @@ "process.new.notification.error.max-upload.content": "The file exceeds the maximum upload size", + "process.new.notification.process.processing": "Processing...", + + "process.new.notification.process.files": "Output Files: ", + + "process.new.notification.process.status.completed": "Completed", + + "process.new.notification.process.status.failed": "Failed", + "process.new.header": "Create a new process", "process.new.title": "Create a new process", diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts index 5e6df2bc21c..6ab705a416a 100644 --- a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -10,6 +10,7 @@ import { GenericItemPageFieldComponent } from '../../../../../../../app/item-pag import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../../../../app/shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; @@ -25,6 +26,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the imports: [ AsyncPipe, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, RelatedItemsComponent, diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts index 9425fb34bac..d99de7ab15e 100644 --- a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -10,6 +10,7 @@ import { GenericItemPageFieldComponent } from '../../../../../../../app/item-pag import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../../../../app/shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; @@ -25,6 +26,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the imports: [ AsyncPipe, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, RelatedItemsComponent, diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index d43d601ca1d..bae1afbf1c2 100644 --- a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -11,6 +11,7 @@ import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item import { TabbedRelatedEntitiesSearchComponent } from '../../../../../../../app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../../../../app/shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; @@ -26,6 +27,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the imports: [ AsyncPipe, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, MetadataFieldWrapperComponent, RelatedItemsComponent, diff --git a/src/themes/custom/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/themes/custom/app/entity-groups/research-entities/item-pages/person/person.component.ts index 6b3a91fdf4d..5af7ab5d166 100644 --- a/src/themes/custom/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/themes/custom/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -16,6 +16,7 @@ import { ThemedThumbnailComponent } from 'src/app/thumbnail/themed-thumbnail.com import { PersonComponent as BaseComponent } from '../../../../../../../app/entity-groups/research-entities/item-pages/person/person.component'; import { ItemPageOrcidFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component'; import { AuthorityRelatedEntitiesSearchComponent } from '../../../../../../../app/item-page/simple/related-entities/authority-related-entities-search/authority-related-entities-search.component'; +import { DsoPublicMenuComponent } from '../../../../../../../app/shared/dso-page/dso-public-menu/dso-public-menu.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('Person', ViewMode.StandalonePage, Context.Any, 'custom') @@ -29,6 +30,7 @@ import { listableObjectComponent } from '../../../../../../../app/shared/object- AsyncPipe, AuthorityRelatedEntitiesSearchComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, ItemPageOrcidFieldComponent, MetadataFieldWrapperComponent, diff --git a/src/themes/custom/app/item-page/full/full-item-page.component.ts b/src/themes/custom/app/item-page/full/full-item-page.component.ts index b0ac5b03f3a..dd2474a9ef6 100644 --- a/src/themes/custom/app/item-page/full/full-item-page.component.ts +++ b/src/themes/custom/app/item-page/full/full-item-page.component.ts @@ -18,6 +18,7 @@ import { ItemVersionsComponent } from '../../../../../app/item-page/versions/ite import { ItemVersionsNoticeComponent } from '../../../../../app/item-page/versions/notice/item-versions-notice.component'; import { fadeInOut } from '../../../../../app/shared/animations/fade'; import { DsoEditMenuComponent } from '../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../../app/shared/dso-page/dso-public-menu/dso-public-menu.component'; import { ErrorComponent } from '../../../../../app/shared/error/error.component'; import { ThemedLoadingComponent } from '../../../../../app/shared/loading/themed-loading.component'; import { VarDirective } from '../../../../../app/shared/utils/var.directive'; @@ -34,6 +35,7 @@ import { VarDirective } from '../../../../../app/shared/utils/var.directive'; AsyncPipe, CollectionsComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, ErrorComponent, ItemVersionsComponent, ItemVersionsNoticeComponent, diff --git a/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts b/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts index e20019c3750..83b64b2654a 100644 --- a/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts @@ -24,6 +24,7 @@ import { ThemedMetadataRepresentationListComponent } from '../../../../../../../ import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; import { AttachmentSectionComponent } from '../../../../../../../app/shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../../../../app/shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; @@ -42,6 +43,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, GeospatialItemPageFieldComponent, ItemPageAbstractFieldComponent, diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index aa46661a601..b3b7af1580b 100644 --- a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -24,6 +24,7 @@ import { UntypedItemComponent as BaseComponent } from '../../../../../../../app/ import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; import { AttachmentSectionComponent } from '../../../../../../../app/shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; +import { DsoPublicMenuComponent } from '../../../../../../../app/shared/dso-page/dso-public-menu/dso-public-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; @@ -44,6 +45,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, + DsoPublicMenuComponent, GenericItemPageFieldComponent, GeospatialItemPageFieldComponent, ItemPageAbstractFieldComponent,