diff --git a/README.md b/README.md index c90dc1d08f..689c64a292 100644 --- a/README.md +++ b/README.md @@ -413,8 +413,7 @@ dspace-angular │ ├── merge-i18n-files.ts * │ ├── serve.ts * │ ├── sync-i18n-files.ts * -│ ├── test-rest.ts * -│ └── webpack.js * +│ └── test-rest.ts * ├── src * The source of the application │ ├── app * The source code of the application, subdivided by module/page. │ ├── assets * Folder for static resources diff --git a/scripts/webpack.js b/scripts/webpack.js deleted file mode 100644 index 93f17b4619..0000000000 --- a/scripts/webpack.js +++ /dev/null @@ -1,13 +0,0 @@ -const path = require('path'); -const child_process = require('child_process'); - -const heapSize = 4096; -const webpackPath = path.join('node_modules', 'webpack', 'bin', 'webpack.js'); - -const params = [ - '--max_old_space_size=' + heapSize, - webpackPath, - ...process.argv.slice(2) -]; - -child_process.spawn('node', params, { stdio:'inherit' }); diff --git a/src/app/bitstream-page/bitstream-page-routing.module.ts b/src/app/bitstream-page/bitstream-page-routing.module.ts index c2abe511a4..3960ccb743 100644 --- a/src/app/bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/bitstream-page/bitstream-page-routing.module.ts @@ -1,6 +1,5 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { BitstreamPageResolver } from './bitstream-page.resolver'; import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component'; @@ -13,6 +12,7 @@ import { LegacyBitstreamUrlResolver } from './legacy-bitstream-url.resolver'; import { BitstreamBreadcrumbResolver } from '../core/breadcrumbs/bitstream-breadcrumb.resolver'; import { BitstreamBreadcrumbsService } from '../core/breadcrumbs/bitstream-breadcrumbs.service'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ThemedEditBitstreamPageComponent } from './edit-bitstream-page/themed-edit-bitstream-page.component'; const EDIT_BITSTREAM_PATH = ':id/edit'; const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; @@ -49,7 +49,7 @@ const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; }, { path: EDIT_BITSTREAM_PATH, - component: EditBitstreamPageComponent, + component: ThemedEditBitstreamPageComponent, resolve: { bitstream: BitstreamPageResolver, breadcrumb: BitstreamBreadcrumbResolver, diff --git a/src/app/bitstream-page/bitstream-page.module.ts b/src/app/bitstream-page/bitstream-page.module.ts index 992f714bf9..9bbe3d200c 100644 --- a/src/app/bitstream-page/bitstream-page.module.ts +++ b/src/app/bitstream-page/bitstream-page.module.ts @@ -7,6 +7,7 @@ import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bit import { FormModule } from '../shared/form/form.module'; import { ResourcePoliciesModule } from '../shared/resource-policies/resource-policies.module'; import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component'; +import { ThemedEditBitstreamPageComponent } from './edit-bitstream-page/themed-edit-bitstream-page.component'; /** * This module handles all components that are necessary for Bitstream related pages @@ -22,6 +23,7 @@ import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstr declarations: [ BitstreamAuthorizationsComponent, EditBitstreamPageComponent, + ThemedEditBitstreamPageComponent, BitstreamDownloadPageComponent, ] }) diff --git a/src/app/bitstream-page/edit-bitstream-page/themed-edit-bitstream-page.component.ts b/src/app/bitstream-page/edit-bitstream-page/themed-edit-bitstream-page.component.ts new file mode 100644 index 0000000000..dcca28a495 --- /dev/null +++ b/src/app/bitstream-page/edit-bitstream-page/themed-edit-bitstream-page.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { EditBitstreamPageComponent } from './edit-bitstream-page.component'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; + +@Component({ + selector: 'ds-themed-edit-bitstream-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedEditBitstreamPageComponent extends ThemedComponent { + protected getComponentName(): string { + return 'EditBitstreamPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./edit-bitstream-page.component'); + } +} diff --git a/src/app/browse-by/browse-by-guard.spec.ts b/src/app/browse-by/browse-by-guard.spec.ts index 671e098762..9ef76b1212 100644 --- a/src/app/browse-by/browse-by-guard.spec.ts +++ b/src/app/browse-by/browse-by-guard.spec.ts @@ -2,8 +2,8 @@ import { first } from 'rxjs/operators'; import { BrowseByGuard } from './browse-by-guard'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator'; +import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; import { DSONameServiceMock } from '../shared/mocks/dso-name.service.mock'; import { DSONameService } from '../core/breadcrumbs/dso-name.service'; @@ -20,7 +20,7 @@ describe('BrowseByGuard', () => { const id = 'author'; const scope = '1234-65487-12354-1235'; const value = 'Filter'; - const browseDefinition = Object.assign(new BrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); + const browseDefinition = Object.assign(new ValueListBrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); beforeEach(() => { dsoService = { diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts index ceb4c6a6c6..b59a46cae1 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts @@ -26,7 +26,7 @@ const map = new Map(); * @param browseByType The type of page * @param theme The optional theme for the component */ -export function rendersBrowseBy(browseByType: BrowseByDataType, theme = DEFAULT_THEME) { +export function rendersBrowseBy(browseByType: string, theme = DEFAULT_THEME) { return function decorator(component: any) { if (hasNoValue(map.get(browseByType))) { map.set(browseByType, new Map()); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index c2e1c9cb68..c13405dd4d 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -3,9 +3,11 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator'; -import { BrowseDefinition } from '../../core/shared/browse-definition.model'; import { BehaviorSubject } from 'rxjs'; import { ThemeService } from '../../shared/theme-support/theme.service'; +import { FlatBrowseDefinition } from '../../core/shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../../core/shared/value-list-browse-definition.model'; +import { NonHierarchicalBrowseDefinition } from '../../core/shared/non-hierarchical-browse-definition'; describe('BrowseBySwitcherComponent', () => { let comp: BrowseBySwitcherComponent; @@ -13,33 +15,33 @@ describe('BrowseBySwitcherComponent', () => { const types = [ Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'title', dataType: BrowseByDataType.Title, } ), Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'dateissued', dataType: BrowseByDataType.Date, metadataKeys: ['dc.date.issued'] } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'author', dataType: BrowseByDataType.Metadata, } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'subject', dataType: BrowseByDataType.Metadata, } ), ]; - const data = new BehaviorSubject(createDataWithBrowseDefinition(new BrowseDefinition())); + const data = new BehaviorSubject(createDataWithBrowseDefinition(new FlatBrowseDefinition())); const activatedRouteStub = { data @@ -70,7 +72,7 @@ describe('BrowseBySwitcherComponent', () => { comp = fixture.componentInstance; })); - types.forEach((type: BrowseDefinition) => { + types.forEach((type: NonHierarchicalBrowseDefinition) => { describe(`when switching to a browse-by page for "${type.id}"`, () => { beforeEach(() => { data.next(createDataWithBrowseDefinition(type)); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index 0d3a35bebf..35e4edf900 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -31,7 +31,7 @@ export class BrowseBySwitcherComponent implements OnInit { */ ngOnInit(): void { this.browseByComponent = this.route.data.pipe( - map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType, this.themeService.getThemeName())) + map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName())) ); } diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html new file mode 100644 index 0000000000..87c7937b1b --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html @@ -0,0 +1,10 @@ + diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts new file mode 100644 index 0000000000..c724017b1f --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { createDataWithBrowseDefinition } from '../browse-by-switcher/browse-by-switcher.component.spec'; +import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; +import { ThemeService } from '../../shared/theme-support/theme.service'; + +describe('BrowseByTaxonomyPageComponent', () => { + let component: BrowseByTaxonomyPageComponent; + let fixture: ComponentFixture; + let themeService: ThemeService; + let detail1: VocabularyEntryDetail; + let detail2: VocabularyEntryDetail; + + const data = new BehaviorSubject(createDataWithBrowseDefinition(new HierarchicalBrowseDefinition())); + const activatedRouteStub = { + data + }; + + beforeEach(async () => { + themeService = jasmine.createSpyObj('themeService', { + getThemeName: 'dspace', + }); + + await TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ BrowseByTaxonomyPageComponent ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: ThemeService, useValue: themeService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByTaxonomyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + detail1 = new VocabularyEntryDetail(); + detail2 = new VocabularyEntryDetail(); + detail1.value = 'HUMANITIES and RELIGION'; + detail2.value = 'TECHNOLOGY'; + detail1.id = 'id-1'; + detail2.id = 'id-2'; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should handle select event', () => { + component.onSelect(detail1); + expect(component.selectedItems.length).toBe(1); + expect(component.selectedItems).toContain(detail1); + expect(component.selectedItems.length).toBe(1); + expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals'] ); + }); + + it('should handle select event with multiple selected items', () => { + component.onSelect(detail1); + component.onSelect(detail2); + expect(component.selectedItems.length).toBe(2); + expect(component.selectedItems).toContain(detail1, detail2); + expect(component.selectedItems.length).toBe(2); + expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'] ); + }); + + it('should handle deselect event', () => { + component.onSelect(detail1); + component.onSelect(detail2); + expect(component.selectedItems.length).toBe(2); + expect(component.selectedItems.length).toBe(2); + component.onDeselect(detail1); + expect(component.selectedItems.length).toBe(1); + expect(component.selectedItems).toContain(detail2); + expect(component.selectedItems.length).toBe(1); + expect(component.filterValues).toEqual(['TECHNOLOGY,equals'] ); + }); + + afterEach(() => { + fixture.destroy(); + component = null; + }); +}); diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts new file mode 100644 index 0000000000..cf6345bf39 --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts @@ -0,0 +1,118 @@ +import { Component, OnInit, Inject, OnDestroy } from '@angular/core'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { BROWSE_BY_COMPONENT_FACTORY } from '../browse-by-switcher/browse-by-decorator'; +import { map } from 'rxjs/operators'; +import { ThemeService } from 'src/app/shared/theme-support/theme.service'; +import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; + +@Component({ + selector: 'ds-browse-by-taxonomy-page', + templateUrl: './browse-by-taxonomy-page.component.html', + styleUrls: ['./browse-by-taxonomy-page.component.scss'] +}) +/** + * Component for browsing items by metadata in a hierarchical controlled vocabulary + */ +export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { + + /** + * The {@link VocabularyOptions} object + */ + vocabularyOptions: VocabularyOptions; + + /** + * The selected vocabulary entries + */ + selectedItems: VocabularyEntryDetail[] = []; + + /** + * The query parameters, contain the selected entries + */ + filterValues: string[]; + + /** + * The facet the use when filtering + */ + facetType: string; + + /** + * The used vocabulary + */ + vocabularyName: string; + + /** + * The parameters used in the URL + */ + queryParams: any; + + /** + * Resolved browse-by component + */ + browseByComponent: Observable; + + /** + * Subscriptions to track + */ + browseByComponentSubs: Subscription[] = []; + + public constructor( protected route: ActivatedRoute, + protected themeService: ThemeService, + @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { + } + + ngOnInit(): void { + this.browseByComponent = this.route.data.pipe( + map((data: { browseDefinition: BrowseDefinition }) => { + this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName()); + return data.browseDefinition; + }) + ); + this.browseByComponentSubs.push(this.browseByComponent.subscribe((browseDefinition: HierarchicalBrowseDefinition) => { + this.facetType = browseDefinition.facetType; + this.vocabularyName = browseDefinition.vocabulary; + this.vocabularyOptions = { name: this.vocabularyName, closed: true }; + })); + } + + /** + * Adds detail to selectedItems, transforms it to be used as query parameter + * and adds that to filterValues. + * + * @param detail VocabularyEntryDetail to be added + */ + onSelect(detail: VocabularyEntryDetail): void { + this.selectedItems.push(detail); + this.filterValues = this.selectedItems + .map((item: VocabularyEntryDetail) => `${item.value},equals`); + this.updateQueryParams(); + } + + /** + * Removes detail from selectedItems and filterValues. + * + * @param detail VocabularyEntryDetail to be removed + */ + onDeselect(detail: VocabularyEntryDetail): void { + this.selectedItems = this.selectedItems.filter((entry: VocabularyEntryDetail) => { return entry.id !== detail.id; }); + this.filterValues = this.filterValues.filter((value: string) => { return value !== `${detail.value},equals`; }); + this.updateQueryParams(); + } + + /** + * Updates queryParams based on the current facetType and filterValues. + */ + private updateQueryParams(): void { + this.queryParams = { + ['f.' + this.facetType]: this.filterValues + }; + } + + ngOnDestroy(): void { + this.browseByComponentSubs.forEach((sub: Subscription) => sub.unsubscribe()); + } +} diff --git a/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts new file mode 100644 index 0000000000..212044b853 --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts @@ -0,0 +1,28 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; + +@Component({ + selector: 'ds-themed-browse-by-taxonomy-page', + templateUrl: '../../shared/theme-support/themed.component.html', + styleUrls: [] +}) +/** + * Themed wrapper for BrowseByTaxonomyPageComponent + */ +@rendersBrowseBy('hierarchy') +export class ThemedBrowseByTaxonomyPageComponent extends ThemedComponent{ + + protected getComponentName(): string { + return 'BrowseByTaxonomyPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-taxonomy-page.component`); + } +} diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index 6c652376d5..c0e2d3f9ff 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -4,24 +4,28 @@ import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-tit import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component'; import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component'; +import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/browse-by-taxonomy-page.component'; import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; import { ComcolModule } from '../shared/comcol/comcol.module'; import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component'; import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component'; import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component'; +import { ThemedBrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; +import { FormModule } from '../shared/form/form.module'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator BrowseByTitlePageComponent, BrowseByMetadataPageComponent, BrowseByDatePageComponent, + BrowseByTaxonomyPageComponent, ThemedBrowseByMetadataPageComponent, ThemedBrowseByDatePageComponent, ThemedBrowseByTitlePageComponent, - + ThemedBrowseByTaxonomyPageComponent, ]; @NgModule({ @@ -29,7 +33,8 @@ const ENTRY_COMPONENTS = [ SharedBrowseByModule, CommonModule, ComcolModule, - DsoPageModule + DsoPageModule, + FormModule, ], declarations: [ BrowseBySwitcherComponent, diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 88d070000e..bc495a51f4 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,20 +1,60 @@ +// eslint-disable-next-line max-classes-per-file import { Injectable } from '@angular/core'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { BrowseDefinition } from '../shared/browse-definition.model'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list.model'; import { FindListOptions } from '../data/find-list-options.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { dataService } from '../data/base/data-service.decorator'; +import { isNotEmpty, isNotEmptyOperator, hasValue } from '../../shared/empty.util'; +import { take } from 'rxjs/operators'; +import { BrowseDefinitionRestRequest } from '../data/request.models'; import { RequestParam } from '../cache/models/request-param.model'; import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { BrowseDefinition } from '../shared/browse-definition.model'; + +/** + * Create a GET request for the given href, and send it. + * Use a GET request specific for BrowseDefinitions. + */ +export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestService, + responseMsToLive: number, + href$: string | Observable, + useCachedVersionIfAvailable: boolean = true): void => { + if (isNotEmpty(href$)) { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + href$.pipe( + isNotEmptyOperator(), + take(1) + ).subscribe((href: string) => { + const requestId = requestService.generateRequestId(); + const request = new BrowseDefinitionRestRequest(requestId, href); + if (hasValue(responseMsToLive)) { + request.responseMsToLive = responseMsToLive; + } + requestService.send(request, useCachedVersionIfAvailable); + }); + } +}; + +/** + * Custom extension of {@link FindAllDataImpl} to be able to send BrowseDefinitionRestRequests + */ +class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl { + createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable: boolean = true) { + createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); + } +} /** * Data service responsible for retrieving browse definitions from the REST server @@ -24,7 +64,7 @@ import { SearchData, SearchDataImpl } from '../data/base/search-data'; }) @dataService(BROWSE_DEFINITION) export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { - private findAllData: FindAllDataImpl; + private findAllData: BrowseDefinitionFindAllDataImpl; private searchData: SearchDataImpl; constructor( @@ -35,7 +75,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService, useCachedVersionIfAvailable: boolean = true) { + createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); + } } diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 46ac8c44f4..9f166e3d19 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -6,13 +6,15 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseService } from './browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList, getFirstUsedArgumentOfSpyMethod } from '../../shared/testing/utils.test'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; import { RequestEntry } from '../data/request-entry.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; describe('BrowseService', () => { let scheduler: TestScheduler; @@ -23,9 +25,9 @@ describe('BrowseService', () => { const browsesEndpointURL = 'https://rest.api/browses'; const halService: any = new HALEndpointServiceStub(browsesEndpointURL); const browseDefinitions = [ - Object.assign(new BrowseDefinition(), { + Object.assign(new FlatBrowseDefinition(), { id: 'date', - metadataBrowse: false, + browseType: 'flatBrowse', sortOptions: [ { name: 'title', @@ -50,9 +52,9 @@ describe('BrowseService', () => { items: { href: 'https://rest.api/discover/browses/dateissued/items' } } }), - Object.assign(new BrowseDefinition(), { + Object.assign(new ValueListBrowseDefinition(), { id: 'author', - metadataBrowse: true, + browseType: 'valueList', sortOptions: [ { name: 'title', @@ -78,7 +80,23 @@ describe('BrowseService', () => { entries: { href: 'https://rest.api/discover/browses/author/entries' }, items: { href: 'https://rest.api/discover/browses/author/items' } } - }) + }), + Object.assign(new HierarchicalBrowseDefinition(), { + id: 'srsc', + browseType: 'hierarchicalBrowse', + facetType: 'subject', + vocabulary: 'srsc', + type: 'browse', + metadata: [ + 'dc.subject' + ], + _links: { + vocabulary: { 'href': 'https://rest.api/submission/vocabularies/srsc/' }, + items: { 'href': 'https://rest.api/discover/browses/srsc/items' }, + entries: { 'href': 'https://rest.api/discover/browses/srsc/entries' }, + self: { 'href': 'https://rest.api/discover/browses/srsc' } + } + }), ]; let browseDefinitionDataService; @@ -140,7 +158,7 @@ describe('BrowseService', () => { describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { it('should call hrefOnlyDataService.findListByHref with the expected href', () => { - const expected = browseDefinitions[1]._links.entries.href; + const expected = (browseDefinitions[1] as ValueListBrowseDefinition)._links.entries.href; scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 989213a978..b210b34949 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -7,6 +7,7 @@ import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; @@ -240,7 +241,12 @@ export class BrowseService { getPaginatedListPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions .find((def: BrowseDefinition) => { - const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + let matchingKeys = ''; + + if (Array.isArray((def as FlatBrowseDefinition).metadataKeys)) { + matchingKeys = (def as FlatBrowseDefinition).metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + } + return isNotEmpty(matchingKeys); }) ), diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index d35900ebe2..8cd24b0140 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -177,6 +177,10 @@ import { IdentifierData } from '../shared/object-list/identifier-data/identifier import { Subscription } from '../shared/subscriptions/models/subscription.model'; import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; import { ItemRequest } from './shared/item-request.model'; +import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-definition.model'; +import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; +import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -333,6 +337,10 @@ export const models = AuthStatus, BrowseEntry, BrowseDefinition, + NonHierarchicalBrowseDefinition, + FlatBrowseDefinition, + ValueListBrowseDefinition, + HierarchicalBrowseDefinition, ClaimedTask, TaskObject, PoolTask, diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index c67eaa2d68..89178f8dd2 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -1,13 +1,14 @@ +import { TestBed } from '@angular/core/testing'; import { BitstreamDataService } from './bitstream-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RequestService } from './request.service'; import { Bitstream } from '../shared/bitstream.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { of as observableOf } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; -import { PutRequest } from './request.models'; +import { PatchRequest, PutRequest } from './request.models'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -15,6 +16,11 @@ import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-bu import { testSearchDataImplementation } from './base/search-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import objectContaining = jasmine.objectContaining; +import { RemoteData } from './remote-data'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -25,10 +31,18 @@ describe('BitstreamDataService', () => { let rdbService: RemoteDataBuildService; const bitstreamFormatHref = 'rest-api/bitstreamformats'; - const bitstream = Object.assign(new Bitstream(), { - uuid: 'fake-bitstream', + const bitstream1 = Object.assign(new Bitstream(), { + id: 'fake-bitstream1', + uuid: 'fake-bitstream1', _links: { - self: { href: 'fake-bitstream-self' } + self: { href: 'fake-bitstream1-self' } + } + }); + const bitstream2 = Object.assign(new Bitstream(), { + id: 'fake-bitstream2', + uuid: 'fake-bitstream2', + _links: { + self: { href: 'fake-bitstream2-self' } } }); const format = Object.assign(new BitstreamFormat(), { @@ -50,7 +64,18 @@ describe('BitstreamDataService', () => { }); rdbService = getMockRemoteDataBuildService(); - service = new BitstreamDataService(requestService, rdbService, objectCache, halService, null, bitstreamFormatService, null, null); + TestBed.configureTestingModule({ + providers: [ + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: HALEndpointService, useValue: halService }, + { provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + ], + }); + service = TestBed.inject(BitstreamDataService); }); describe('composition', () => { @@ -62,11 +87,49 @@ describe('BitstreamDataService', () => { describe('when updating the bitstream\'s format', () => { beforeEach(() => { - service.updateFormat(bitstream, format); + service.updateFormat(bitstream1, format); }); it('should send a put request', () => { expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PutRequest)); }); }); + + describe('removeMultiple', () => { + function mockBuildFromRequestUUIDAndAwait(requestUUID$: string | Observable, callback: (rd?: RemoteData) => Observable, ..._linksToFollow: FollowLinkConfig[]): Observable> { + callback(); + return; + } + + beforeEach(() => { + spyOn(service, 'invalidateByHref'); + spyOn(rdbService, 'buildFromRequestUUIDAndAwait').and.callFake((requestUUID$: string | Observable, callback: (rd?: RemoteData) => Observable, ...linksToFollow: FollowLinkConfig[]) => mockBuildFromRequestUUIDAndAwait(requestUUID$, callback, ...linksToFollow)); + }); + + it('should be able to 1 bitstream', () => { + service.removeMultiple([bitstream1]); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + href: `${url}/bitstreams`, + body: [ + { op: 'remove', path: '/bitstreams/fake-bitstream1' }, + ], + } as PatchRequest)); + expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self'); + }); + + it('should be able to delete multiple bitstreams', () => { + service.removeMultiple([bitstream1, bitstream2]); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + href: `${url}/bitstreams`, + body: [ + { op: 'remove', path: '/bitstreams/fake-bitstream1' }, + { op: 'remove', path: '/bitstreams/fake-bitstream2' }, + ], + } as PatchRequest)); + expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self'); + expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream2-self'); + }); + }); }); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 6bdcefe187..bb4ec28166 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,7 +1,7 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; +import { find, map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -14,7 +14,7 @@ import { Item } from '../shared/item.model'; import { BundleDataService } from './bundle-data.service'; import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { PutRequest } from './request.models'; +import { PatchRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { BitstreamFormatDataService } from './bitstream-format-data.service'; import { BitstreamFormat } from '../shared/bitstream-format.model'; @@ -33,7 +33,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { NoContent } from '../shared/NoContent.model'; import { IdentifiableDataService } from './base/identifiable-data.service'; import { dataService } from './base/data-service.decorator'; -import { Operation } from 'fast-json-patch'; +import { Operation, RemoveOperation } from 'fast-json-patch'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -277,4 +277,34 @@ export class BitstreamDataService extends IdentifiableDataService imp deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.deleteByHref(href, copyVirtualMetadata); } + + /** + * Delete multiple {@link Bitstream}s at once by sending a PATCH request to the backend + * + * @param bitstreams The bitstreams that should be removed + */ + removeMultiple(bitstreams: Bitstream[]): Observable> { + const operations: RemoveOperation[] = bitstreams.map((bitstream: Bitstream) => { + return { + op: 'remove', + path: `/bitstreams/${bitstream.id}`, + }; + }); + const requestId: string = this.requestService.generateRequestId(); + + const hrefObs: Observable = this.halService.getEndpoint(this.linkPath); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new PatchRequest(requestId, href, operations); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => observableCombineLatest(bitstreams.map((bitstream: Bitstream) => this.invalidateByHref(bitstream._links.self.href)))); + } + } diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts new file mode 100644 index 0000000000..9fa7239ef7 --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -0,0 +1,64 @@ +import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; + +class TestService extends BrowseResponseParsingService { + constructor(protected objectCache: ObjectCacheService) { + super(objectCache); + } + + // Overwrite method to make it public for testing + public deserialize(obj): any { + return super.deserialize(obj); + } +} + +describe('BrowseResponseParsingService', () => { + let service: TestService; + + + beforeEach(() => { + service = new TestService(getMockObjectCacheService()); + }); + + describe('', () => { + const mockFlatBrowse = { + id: 'title', + browseType: 'flatBrowse', + type: 'browse', + }; + + const mockValueList = { + id: 'author', + browseType: 'valueList', + type: 'browse', + }; + + const mockHierarchicalBrowse = { + id: 'srsc', + browseType: 'hierarchicalBrowse', + type: 'browse', + }; + + it('should deserialize flatBrowses correctly', () => { + let deserialized = service.deserialize(mockFlatBrowse); + expect(deserialized.type).toBe(FLAT_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockFlatBrowse.id); + }); + + it('should deserialize valueList browses correctly', () => { + let deserialized = service.deserialize(mockValueList); + expect(deserialized.type).toBe(VALUE_LIST_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockValueList.id); + }); + + it('should deserialize hierarchicalBrowses correctly', () => { + let deserialized = service.deserialize(mockHierarchicalBrowse); + expect(deserialized.type).toBe(HIERARCHICAL_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockHierarchicalBrowse.id); + }); + }); +}); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts new file mode 100644 index 0000000000..a568cdb617 --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { hasValue } from '../../shared/empty.util'; +import { + HIERARCHICAL_BROWSE_DEFINITION +} from '../shared/hierarchical-browse-definition.resource-type'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { Serializer } from '../serializer'; +import { BrowseDefinition } from '../shared/browse-definition.model'; +import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; +import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; + +/** + * A ResponseParsingService used to parse a REST API response to a BrowseDefinition object + */ +@Injectable({ + providedIn: 'root', +}) +export class BrowseResponseParsingService extends DspaceRestResponseParsingService { + constructor( + protected objectCache: ObjectCacheService, + ) { + super(objectCache); + } + + protected deserialize(obj): any { + const browseType: string = obj.browseType; + if (obj.type === BROWSE_DEFINITION.value && hasValue(browseType)) { + let serializer: Serializer; + if (browseType === HIERARCHICAL_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(HierarchicalBrowseDefinition); + } else if (browseType === FLAT_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(FlatBrowseDefinition); + } else if (browseType === VALUE_LIST_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(ValueListBrowseDefinition); + } else { + throw new Error('An error occurred while retrieving the browse definitions.'); + } + return serializer.deserialize(obj); + } else { + throw new Error('An error occurred while retrieving the browse definitions.'); + } + } +} diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index fd5a22fae9..74117e79d3 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -10,6 +10,10 @@ import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; import { RestRequest } from './rest-request.model'; +/** + * @deprecated use DspaceRestResponseParsingService for new code, this is only left to support a + * few legacy use cases, and should get removed eventually + */ @Injectable() export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected toCache = true; diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 6ab3f180d3..9809bc0fde 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -11,6 +11,7 @@ import { TaskResponseParsingService } from '../tasks/task-response-parsing.servi import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { FindListOptions } from './find-list-options.model'; @@ -118,6 +119,15 @@ export class PatchRequest extends DSpaceRestRequest { } } +/** + * Class representing a BrowseDefinition HTTP Rest request object + */ +export class BrowseDefinitionRestRequest extends DSpaceRestRequest { + getResponseParser(): GenericConstructor { + return BrowseResponseParsingService; + } +} + export class FindListRequest extends GetRequest { constructor( uuid: string, diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index 863f454422..a5bed53c9f 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -1,50 +1,16 @@ -import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; -import { excludeFromEquals } from '../utilities/equals.decorators'; -import { BROWSE_DEFINITION } from './browse-definition.resource-type'; -import { HALLink } from './hal-link.model'; -import { ResourceType } from './resource-type'; -import { SortOption } from './sort-option.model'; +import { autoserialize } from 'cerialize'; import { CacheableObject } from '../cache/cacheable-object.model'; -import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator'; -@typedObject -export class BrowseDefinition extends CacheableObject { - static type = BROWSE_DEFINITION; - - /** - * The object type - */ - @excludeFromEquals - @autoserialize - type: ResourceType; +/** + * Base class for BrowseDefinition models + */ +export abstract class BrowseDefinition extends CacheableObject { @autoserialize id: string; - @autoserialize - metadataBrowse: boolean; - - @autoserialize - sortOptions: SortOption[]; - - @autoserializeAs('order') - defaultSortOrder: string; - - @autoserializeAs('metadata') - metadataKeys: string[]; - - @autoserialize - dataType: BrowseByDataType; - - get self(): string { - return this._links.self.href; - } - - @deserialize - _links: { - self: HALLink; - entries: HALLink; - items: HALLink; - }; + /** + * Get the render type of the BrowseDefinition model + */ + abstract getRenderType(): string; } diff --git a/src/app/core/shared/flat-browse-definition.model.ts b/src/app/core/shared/flat-browse-definition.model.ts new file mode 100644 index 0000000000..086fca891b --- /dev/null +++ b/src/app/core/shared/flat-browse-definition.model.ts @@ -0,0 +1,36 @@ +import { inheritSerialization, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { FLAT_BROWSE_DEFINITION } from './flat-browse-definition.resource-type'; +import { ResourceType } from './resource-type'; +import { NonHierarchicalBrowseDefinition } from './non-hierarchical-browse-definition'; +import { HALLink } from './hal-link.model'; + +/** + * BrowseDefinition model for browses of type 'flatBrowse' + */ +@typedObject +@inheritSerialization(NonHierarchicalBrowseDefinition) +export class FlatBrowseDefinition extends NonHierarchicalBrowseDefinition { + static type = FLAT_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = FLAT_BROWSE_DEFINITION; + + get self(): string { + return this._links.self.href; + } + + @deserialize + _links: { + self: HALLink; + items: HALLink; + }; + + getRenderType(): string { + return this.dataType; + } +} diff --git a/src/app/core/shared/flat-browse-definition.resource-type.ts b/src/app/core/shared/flat-browse-definition.resource-type.ts new file mode 100644 index 0000000000..bfb01cd98c --- /dev/null +++ b/src/app/core/shared/flat-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for FlatBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const FLAT_BROWSE_DEFINITION = new ResourceType('flatBrowse'); diff --git a/src/app/core/shared/hierarchical-browse-definition.model.ts b/src/app/core/shared/hierarchical-browse-definition.model.ts new file mode 100644 index 0000000000..d561fff643 --- /dev/null +++ b/src/app/core/shared/hierarchical-browse-definition.model.ts @@ -0,0 +1,45 @@ +import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { HIERARCHICAL_BROWSE_DEFINITION } from './hierarchical-browse-definition.resource-type'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; +import { BrowseDefinition } from './browse-definition.model'; + +/** + * BrowseDefinition model for browses of type 'hierarchicalBrowse' + */ +@typedObject +@inheritSerialization(BrowseDefinition) +export class HierarchicalBrowseDefinition extends BrowseDefinition { + static type = HIERARCHICAL_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = HIERARCHICAL_BROWSE_DEFINITION; + + @autoserialize + facetType: string; + + @autoserialize + vocabulary: string; + + @autoserializeAs('metadata') + metadataKeys: string[]; + + get self(): string { + return this._links.self.href; + } + + @deserialize + _links: { + self: HALLink; + vocabulary: HALLink; + }; + + getRenderType(): string { + return 'hierarchy'; + } +} diff --git a/src/app/core/shared/hierarchical-browse-definition.resource-type.ts b/src/app/core/shared/hierarchical-browse-definition.resource-type.ts new file mode 100644 index 0000000000..df06d67c7a --- /dev/null +++ b/src/app/core/shared/hierarchical-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for HierarchicalBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const HIERARCHICAL_BROWSE_DEFINITION = new ResourceType('hierarchicalBrowse'); diff --git a/src/app/core/shared/non-hierarchical-browse-definition.ts b/src/app/core/shared/non-hierarchical-browse-definition.ts new file mode 100644 index 0000000000..d5481fcc8d --- /dev/null +++ b/src/app/core/shared/non-hierarchical-browse-definition.ts @@ -0,0 +1,24 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { SortOption } from './sort-option.model'; +import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator'; +import { BrowseDefinition } from './browse-definition.model'; + +/** + * Super class for NonHierarchicalBrowseDefinition models, + * e.g. FlatBrowseDefinition and ValueListBrowseDefinition + */ +@inheritSerialization(BrowseDefinition) +export abstract class NonHierarchicalBrowseDefinition extends BrowseDefinition { + + @autoserialize + sortOptions: SortOption[]; + + @autoserializeAs('order') + defaultSortOrder: string; + + @autoserializeAs('metadata') + metadataKeys: string[]; + + @autoserialize + dataType: BrowseByDataType; +} diff --git a/src/app/core/shared/value-list-browse-definition.model.ts b/src/app/core/shared/value-list-browse-definition.model.ts new file mode 100644 index 0000000000..3378263962 --- /dev/null +++ b/src/app/core/shared/value-list-browse-definition.model.ts @@ -0,0 +1,36 @@ +import { inheritSerialization, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { VALUE_LIST_BROWSE_DEFINITION } from './value-list-browse-definition.resource-type'; +import { ResourceType } from './resource-type'; +import { NonHierarchicalBrowseDefinition } from './non-hierarchical-browse-definition'; +import { HALLink } from './hal-link.model'; + +/** + * BrowseDefinition model for browses of type 'valueList' + */ +@typedObject +@inheritSerialization(NonHierarchicalBrowseDefinition) +export class ValueListBrowseDefinition extends NonHierarchicalBrowseDefinition { + static type = VALUE_LIST_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = VALUE_LIST_BROWSE_DEFINITION; + + get self(): string { + return this._links.self.href; + } + + @deserialize + _links: { + self: HALLink; + entries: HALLink; + }; + + getRenderType(): string { + return this.dataType; + } +} diff --git a/src/app/core/shared/value-list-browse-definition.resource-type.ts b/src/app/core/shared/value-list-browse-definition.resource-type.ts new file mode 100644 index 0000000000..8904dc472f --- /dev/null +++ b/src/app/core/shared/value-list-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ValueListBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const VALUE_LIST_BROWSE_DEFINITION = new ResourceType('valueList'); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index f2c1747658..1ff5b30ee0 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -223,13 +223,15 @@ export class VocabularyService { * no valid cached version. Defaults to true * @param reRequestOnStale Whether or not the request should automatically be re- * requested after the response becomes stale + * @param constructId Whether constructing the full vocabularyDetail ID + * ({vocabularyName}:{detailName}) is still necessary * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved * @return {Observable>} * Return an observable that emits VocabularyEntryDetail object */ - findEntryDetailById(id: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - const findId = `${name}:${id}`; + findEntryDetailById(id: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, constructId: boolean = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findId: string = (constructId ? `${name}:${id}` : id); return this.vocabularyEntryDetailDataService.findById(findId, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } diff --git a/src/app/item-page/edit-item-page/edit-item-page.module.ts b/src/app/item-page/edit-item-page/edit-item-page.module.ts index 7692df8924..5a2b5ba1f1 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.module.ts @@ -39,6 +39,8 @@ import { IdentifierDataComponent } from '../../shared/object-list/identifier-dat import { ItemRegisterDoiComponent } from './item-register-doi/item-register-doi.component'; import { DsoSharedModule } from '../../dso-shared/dso-shared.module'; import { ItemCurateComponent } from './item-curate/item-curate.component'; +import { ThemedItemStatusComponent } from './item-status/themed-item-status.component'; + /** * Module that contains all components related to the Edit Item page administrator functionality @@ -67,6 +69,7 @@ import { ItemCurateComponent } from './item-curate/item-curate.component'; ItemPublicComponent, ItemDeleteComponent, ItemStatusComponent, + ThemedItemStatusComponent, ItemRelationshipsComponent, ItemBitstreamsComponent, ItemVersionHistoryComponent, @@ -89,6 +92,9 @@ import { ItemCurateComponent } from './item-curate/item-curate.component'; IdentifierDataService, ObjectValuesPipe ], + exports: [ + ItemOperationComponent, + ] }) export class EditItemPageModule { diff --git a/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts index 9923748338..22eb624db7 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.routing.module.ts @@ -6,7 +6,6 @@ import { ItemReinstateComponent } from './item-reinstate/item-reinstate.componen import { ItemPrivateComponent } from './item-private/item-private.component'; import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; -import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemMoveComponent } from './item-move/item-move.component'; @@ -42,6 +41,7 @@ import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.gua import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component'; import { ItemPageRegisterDoiGuard } from './item-page-register-doi.guard'; import { ItemCurateComponent } from './item-curate/item-curate.component'; +import { ThemedItemStatusComponent } from './item-status/themed-item-status.component'; /** * Routing module that handles the routing for the Edit Item page administrator functionality @@ -67,7 +67,7 @@ import { ItemCurateComponent } from './item-curate/item-curate.component'; }, { path: 'status', - component: ItemStatusComponent, + component: ThemedItemStatusComponent, data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true }, canActivate: [ItemPageStatusGuard] }, diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index 67d047d776..10e1812131 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -25,6 +25,7 @@ import { getMockRequestService } from '../../../shared/mocks/request.service.moc import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; +import { BitstreamDataServiceStub } from '../../../shared/testing/bitstream-data-service.stub'; let comp: ItemBitstreamsComponent; let fixture: ComponentFixture; @@ -71,7 +72,7 @@ let objectUpdatesService: ObjectUpdatesService; let router: any; let route: ActivatedRoute; let notificationsService: NotificationsService; -let bitstreamService: BitstreamDataService; +let bitstreamService: BitstreamDataServiceStub; let objectCache: ObjectCacheService; let requestService: RequestService; let searchConfig: SearchConfigurationService; @@ -112,9 +113,7 @@ describe('ItemBitstreamsComponent', () => { success: successNotification } ); - bitstreamService = jasmine.createSpyObj('bitstreamService', { - delete: jasmine.createSpy('delete') - }); + bitstreamService = new BitstreamDataServiceStub(); objectCache = jasmine.createSpyObj('objectCache', { remove: jasmine.createSpy('remove') }); @@ -179,15 +178,16 @@ describe('ItemBitstreamsComponent', () => { describe('when submit is called', () => { beforeEach(() => { + spyOn(bitstreamService, 'removeMultiple').and.callThrough(); comp.submit(); }); - it('should call delete on the bitstreamService for the marked field', () => { - expect(bitstreamService.delete).toHaveBeenCalledWith(bitstream2.id); + it('should call removeMultiple on the bitstreamService for the marked field', () => { + expect(bitstreamService.removeMultiple).toHaveBeenCalledWith([bitstream2]); }); - it('should not call delete on the bitstreamService for the unmarked field', () => { - expect(bitstreamService.delete).not.toHaveBeenCalledWith(bitstream1.id); + it('should not call removeMultiple on the bitstreamService for the unmarked field', () => { + expect(bitstreamService.removeMultiple).not.toHaveBeenCalledWith([bitstream1]); }); }); @@ -210,7 +210,6 @@ describe('ItemBitstreamsComponent', () => { comp.dropBitstream(bundle, { fromIndex: 0, toIndex: 50, - // eslint-disable-next-line no-empty, @typescript-eslint/no-empty-function finish: () => { done(); } diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 0c7dfb1e34..ee53bd919c 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy } from '@angular/core'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { filter, map, switchMap, take } from 'rxjs/operators'; -import { Observable, of as observableOf, Subscription, zip as observableZip } from 'rxjs'; +import { Observable, Subscription, zip as observableZip } from 'rxjs'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { ActivatedRoute, Router } from '@angular/router'; @@ -133,20 +133,16 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme ); // Send out delete requests for all deleted bitstreams - const removedResponses$ = removedBitstreams$.pipe( + const removedResponses$: Observable> = removedBitstreams$.pipe( take(1), - switchMap((removedBistreams: Bitstream[]) => { - if (isNotEmpty(removedBistreams)) { - return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.delete(bitstream.id))); - } else { - return observableOf(undefined); - } + switchMap((removedBitstreams: Bitstream[]) => { + return this.bitstreamService.removeMultiple(removedBitstreams); }) ); // Perform the setup actions from above in order and display notifications - removedResponses$.pipe(take(1)).subscribe((responses: RemoteData[]) => { - this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); + removedResponses$.subscribe((responses: RemoteData) => { + this.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]); this.submitting = false; }); } diff --git a/src/app/item-page/edit-item-page/item-status/themed-item-status.component.ts b/src/app/item-page/edit-item-page/item-status/themed-item-status.component.ts new file mode 100644 index 0000000000..eac5a53702 --- /dev/null +++ b/src/app/item-page/edit-item-page/item-status/themed-item-status.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../../shared/theme-support/themed.component'; +import { ItemStatusComponent } from './item-status.component'; + +@Component({ + selector: 'ds-themed-item-status', + styleUrls: [], + templateUrl: '../../../shared/theme-support/themed.component.html', +}) +export class ThemedItemStatusComponent extends ThemedComponent { + protected getComponentName(): string { + return 'ItemStatusComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../themes/${themeName}/app/item-page/edit-item-page/item-status/item-status.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./item-status.component'); + } + +} diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index 49ce453fbe..cbbae9006d 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -3,6 +3,7 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { BrowseDefinition } from '../../../core/shared/browse-definition.model'; import { hasValue } from '../../../shared/empty.util'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../../../core/shared/value-list-browse-definition.resource-type'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. @@ -84,7 +85,7 @@ export class MetadataValuesComponent implements OnChanges { */ getQueryParams(value) { let queryParams = {startsWith: value}; - if (this.browseDefinition.metadataBrowse) { + if (this.browseDefinition.getRenderType() === VALUE_LIST_BROWSE_DEFINITION.value) { return {value: value}; } return queryParams; diff --git a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html index 9358bcf835..6ba318f7fd 100644 --- a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html +++ b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html @@ -34,11 +34,13 @@
diff --git a/src/app/navbar/navbar.component.spec.ts b/src/app/navbar/navbar.component.spec.ts index ada9be9d0b..983eace055 100644 --- a/src/app/navbar/navbar.component.spec.ts +++ b/src/app/navbar/navbar.component.spec.ts @@ -16,7 +16,6 @@ import { RouterTestingModule } from '@angular/router/testing'; import { BrowseService } from '../core/browse/browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { buildPaginatedList } from '../core/data/paginated-list.model'; -import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from '../browse-by/browse-by-switcher/browse-by-decorator'; import { Item } from '../core/shared/item.model'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; @@ -28,6 +27,9 @@ import { authReducer } from '../core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; import { EPersonMock } from '../shared/testing/eperson.mock'; +import { FlatBrowseDefinition } from '../core/shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; +import { HierarchicalBrowseDefinition } from '../core/shared/hierarchical-browse-definition.model'; let comp: NavbarComponent; let fixture: ComponentFixture; @@ -66,30 +68,35 @@ describe('NavbarComponent', () => { beforeEach(waitForAsync(() => { browseDefinitions = [ Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'title', dataType: BrowseByDataType.Title, } ), Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'dateissued', dataType: BrowseByDataType.Date, metadataKeys: ['dc.date.issued'] } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'author', dataType: BrowseByDataType.Metadata, } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'subject', dataType: BrowseByDataType.Metadata, } ), + Object.assign( + new HierarchicalBrowseDefinition(), { + id: 'srsc', + } + ), ]; initialState = { core: { diff --git a/src/app/shared/context-help-wrapper/context-help-wrapper.component.html b/src/app/shared/context-help-wrapper/context-help-wrapper.component.html index b031d0f42d..083b8163ff 100644 --- a/src/app/shared/context-help-wrapper/context-help-wrapper.component.html +++ b/src/app/shared/context-help-wrapper/context-help-wrapper.component.html @@ -2,7 +2,7 @@
- {{elem.text}} + {{elem.text}} {{ elem }} diff --git a/src/app/shared/dso-page/dso-edit-menu.resolver.ts b/src/app/shared/dso-page/dso-edit-menu.resolver.ts index 749d5580a4..80a69c2830 100644 --- a/src/app/shared/dso-page/dso-edit-menu.resolver.ts +++ b/src/app/shared/dso-page/dso-edit-menu.resolver.ts @@ -50,27 +50,32 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection if (hasNoValue(id) && hasValue(route.queryParams.scope)) { id = route.queryParams.scope; } - return this.dSpaceObjectDataService.findById(id, true, false).pipe( - getFirstCompletedRemoteData(), - switchMap((dsoRD) => { - if (dsoRD.hasSucceeded) { - const dso = dsoRD.payload; - return combineLatest(this.getDsoMenus(dso, route, state)).pipe( - // Menu sections are retrieved as an array of arrays and flattened into a single array - map((combinedMenus) => [].concat.apply([], combinedMenus)), - map((menus) => this.addDsoUuidToMenuIDs(menus, dso)), - map((menus) => { - return { - ...route.data?.menu, - [MenuID.DSO_EDIT]: menus - }; - }) - ); - } else { - return observableOf({...route.data?.menu}); - } - }) - ); + if (hasNoValue(id)) { + // If there's no ID, we're not on a DSO homepage, so pass on any pre-existing menu route data + return observableOf({ ...route.data?.menu }); + } else { + return this.dSpaceObjectDataService.findById(id, true, false).pipe( + getFirstCompletedRemoteData(), + switchMap((dsoRD) => { + if (dsoRD.hasSucceeded) { + const dso = dsoRD.payload; + return combineLatest(this.getDsoMenus(dso, route, state)).pipe( + // Menu sections are retrieved as an array of arrays and flattened into a single array + map((combinedMenus) => [].concat.apply([], combinedMenus)), + map((menus) => this.addDsoUuidToMenuIDs(menus, dso)), + map((menus) => { + return { + ...route.data?.menu, + [MenuID.DSO_EDIT]: menus + }; + }) + ); + } else { + return observableOf({...route.data?.menu}); + } + }) + ); + } } /** diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 251ff36a68..db278716e1 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -30,8 +30,8 @@ import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/ import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; import { Vocabulary } from '../../../../../../core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyTreeviewComponent } from '../../../../vocabulary-treeview/vocabulary-treeview.component'; import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { VocabularyTreeviewModalComponent } from '../../../../vocabulary-treeview-modal/vocabulary-treeview-modal.component'; /** * Component representing a onebox input field. @@ -222,10 +222,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple map((vocabulary: Vocabulary) => vocabulary.preloadLevel), take(1) ).subscribe((preloadLevel) => { - const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewComponent, { size: 'lg', windowClass: 'treeview' }); + const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewModalComponent, { size: 'lg', windowClass: 'treeview' }); modalRef.componentInstance.vocabularyOptions = this.model.vocabularyOptions; modalRef.componentInstance.preloadLevel = preloadLevel; - modalRef.componentInstance.selectedItem = this.currentValue ? this.currentValue : ''; + modalRef.componentInstance.selectedItems = this.currentValue ? [this.currentValue.value] : []; modalRef.result.then((result: VocabularyEntryDetail) => { if (result) { this.currentValue = result; diff --git a/src/app/shared/form/form.module.ts b/src/app/shared/form/form.module.ts index de18c53363..792de6f251 100644 --- a/src/app/shared/form/form.module.ts +++ b/src/app/shared/form/form.module.ts @@ -32,7 +32,7 @@ import { NumberPickerComponent } from './number-picker/number-picker.component'; import { AuthorityConfidenceStateDirective } from './directives/authority-confidence-state.directive'; import { SortablejsModule } from 'ngx-sortablejs'; import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; -import { VocabularyTreeviewService } from './vocabulary-treeview/vocabulary-treeview.service'; +import { VocabularyTreeviewModalComponent } from './vocabulary-treeview-modal/vocabulary-treeview-modal.component'; import { FormBuilderService } from './builder/form-builder.service'; import { DsDynamicTypeBindRelationService } from './builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { FormService } from './form.service'; @@ -71,7 +71,8 @@ const COMPONENTS = [ ChipsComponent, NumberPickerComponent, VocabularyTreeviewComponent, - ThemedExternalSourceEntryImportModalComponent + VocabularyTreeviewModalComponent, + ThemedExternalSourceEntryImportModalComponent, ]; const DIRECTIVES = [ @@ -105,7 +106,6 @@ const DIRECTIVES = [ provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, - VocabularyTreeviewService, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.html b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.html new file mode 100644 index 0000000000..71eb8e1476 --- /dev/null +++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.html @@ -0,0 +1,16 @@ + + diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.scss b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts new file mode 100644 index 0000000000..590c69a159 --- /dev/null +++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VocabularyTreeviewModalComponent } from './vocabulary-treeview-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('VocabularyTreeviewModalComponent', () => { + let component: VocabularyTreeviewModalComponent; + let fixture: ComponentFixture; + + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ VocabularyTreeviewModalComponent ], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(VocabularyTreeviewModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.ts b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.ts new file mode 100644 index 0000000000..c6b0bf20fe --- /dev/null +++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.ts @@ -0,0 +1,51 @@ +import { Component, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; + +@Component({ + selector: 'ds-vocabulary-treeview-modal', + templateUrl: './vocabulary-treeview-modal.component.html', + styleUrls: ['./vocabulary-treeview-modal.component.scss'] +}) +/** + * Component that contains a modal to display a VocabularyTreeviewComponent + */ +export class VocabularyTreeviewModalComponent { + + /** + * The {@link VocabularyOptions} object + */ + @Input() vocabularyOptions: VocabularyOptions; + + /** + * Representing how many tree level load at initialization + */ + @Input() preloadLevel = 2; + + /** + * The vocabulary entries already selected, if any + */ + @Input() selectedItems: string[] = []; + + /** + * Whether to allow selecting multiple values with checkboxes + */ + @Input() multiSelect = false; + + /** + * Initialize instance variables + * + * @param {NgbActiveModal} activeModal + */ + constructor( + public activeModal: NgbActiveModal, + ) { } + + /** + * Method called on entry select + */ + onSelect(item: VocabularyEntryDetail) { + this.activeModal.close(item); + } +} diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts index c167328cab..4ac1b08425 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts @@ -21,7 +21,8 @@ export class TreeviewNode { public pageInfo: PageInfo = new PageInfo(), public loadMoreParentItem: VocabularyEntryDetail | null = null, public isSearchNode = false, - public isInInitValueHierarchy = false) { + public isInInitValueHierarchy = false, + public isSelected = false) { } updatePageInfo(pageInfo: PageInfo) { @@ -38,7 +39,8 @@ export class TreeviewFlatNode { public pageInfo: PageInfo = new PageInfo(), public loadMoreParentItem: VocabularyEntryDetail | null = null, public isSearchNode = false, - public isInInitValueHierarchy = false) { + public isInInitValueHierarchy = false, + public isSelected = false) { } } diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html index 39c62d6e53..fb7d162008 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html @@ -1,77 +1,98 @@ - -