Merge branch 'main' into feature/CST-5729

This commit is contained in:
Giuseppe Digilio
2023-06-06 18:14:39 +02:00
60 changed files with 1237 additions and 291 deletions

View File

@@ -2,8 +2,8 @@ import { first } from 'rxjs/operators';
import { BrowseByGuard } from './browse-by-guard'; import { BrowseByGuard } from './browse-by-guard';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; 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 { 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 { DSONameServiceMock } from '../shared/mocks/dso-name.service.mock';
import { DSONameService } from '../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../core/breadcrumbs/dso-name.service';
@@ -20,7 +20,7 @@ describe('BrowseByGuard', () => {
const id = 'author'; const id = 'author';
const scope = '1234-65487-12354-1235'; const scope = '1234-65487-12354-1235';
const value = 'Filter'; 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(() => { beforeEach(() => {
dsoService = { dsoService = {

View File

@@ -26,7 +26,7 @@ const map = new Map();
* @param browseByType The type of page * @param browseByType The type of page
* @param theme The optional theme for the component * @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) { return function decorator(component: any) {
if (hasNoValue(map.get(browseByType))) { if (hasNoValue(map.get(browseByType))) {
map.set(browseByType, new Map()); map.set(browseByType, new Map());

View File

@@ -3,9 +3,11 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator'; import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator';
import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { ThemeService } from '../../shared/theme-support/theme.service'; 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', () => { describe('BrowseBySwitcherComponent', () => {
let comp: BrowseBySwitcherComponent; let comp: BrowseBySwitcherComponent;
@@ -13,33 +15,33 @@ describe('BrowseBySwitcherComponent', () => {
const types = [ const types = [
Object.assign( Object.assign(
new BrowseDefinition(), { new FlatBrowseDefinition(), {
id: 'title', id: 'title',
dataType: BrowseByDataType.Title, dataType: BrowseByDataType.Title,
} }
), ),
Object.assign( Object.assign(
new BrowseDefinition(), { new FlatBrowseDefinition(), {
id: 'dateissued', id: 'dateissued',
dataType: BrowseByDataType.Date, dataType: BrowseByDataType.Date,
metadataKeys: ['dc.date.issued'] metadataKeys: ['dc.date.issued']
} }
), ),
Object.assign( Object.assign(
new BrowseDefinition(), { new ValueListBrowseDefinition(), {
id: 'author', id: 'author',
dataType: BrowseByDataType.Metadata, dataType: BrowseByDataType.Metadata,
} }
), ),
Object.assign( Object.assign(
new BrowseDefinition(), { new ValueListBrowseDefinition(), {
id: 'subject', id: 'subject',
dataType: BrowseByDataType.Metadata, dataType: BrowseByDataType.Metadata,
} }
), ),
]; ];
const data = new BehaviorSubject(createDataWithBrowseDefinition(new BrowseDefinition())); const data = new BehaviorSubject(createDataWithBrowseDefinition(new FlatBrowseDefinition()));
const activatedRouteStub = { const activatedRouteStub = {
data data
@@ -70,7 +72,7 @@ describe('BrowseBySwitcherComponent', () => {
comp = fixture.componentInstance; comp = fixture.componentInstance;
})); }));
types.forEach((type: BrowseDefinition) => { types.forEach((type: NonHierarchicalBrowseDefinition) => {
describe(`when switching to a browse-by page for "${type.id}"`, () => { describe(`when switching to a browse-by page for "${type.id}"`, () => {
beforeEach(() => { beforeEach(() => {
data.next(createDataWithBrowseDefinition(type)); data.next(createDataWithBrowseDefinition(type));

View File

@@ -31,7 +31,7 @@ export class BrowseBySwitcherComponent implements OnInit {
*/ */
ngOnInit(): void { ngOnInit(): void {
this.browseByComponent = this.route.data.pipe( 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()))
); );
} }

View File

@@ -0,0 +1,10 @@
<div class="container">
<div class="mb-3">
<ds-vocabulary-treeview [vocabularyOptions]=vocabularyOptions
[multiSelect]="true"
(select)="onSelect($event)"
(deselect)="onDeselect($event)">
</ds-vocabulary-treeview>
</div>
<a class="btn btn-primary" [routerLink]="['/search']" [queryParams]="queryParams">{{ 'browse.taxonomy.button' | translate }}</a>
</div>

View File

@@ -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<BrowseByTaxonomyPageComponent>;
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;
});
});

View File

@@ -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<any>;
/**
* Subscriptions to track
*/
browseByComponentSubs: Subscription[] = [];
public constructor( protected route: ActivatedRoute,
protected themeService: ThemeService,
@Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor<any>) {
}
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());
}
}

View File

@@ -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<BrowseByTaxonomyPageComponent>{
protected getComponentName(): string {
return 'BrowseByTaxonomyPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./browse-by-taxonomy-page.component`);
}
}

View File

@@ -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 { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component';
import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-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 { 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 { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component';
import { ComcolModule } from '../shared/comcol/comcol.module'; import { ComcolModule } from '../shared/comcol/comcol.module';
import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component'; 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 { 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 { 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 { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module';
import { FormModule } from '../shared/form/form.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
BrowseByTitlePageComponent, BrowseByTitlePageComponent,
BrowseByMetadataPageComponent, BrowseByMetadataPageComponent,
BrowseByDatePageComponent, BrowseByDatePageComponent,
BrowseByTaxonomyPageComponent,
ThemedBrowseByMetadataPageComponent, ThemedBrowseByMetadataPageComponent,
ThemedBrowseByDatePageComponent, ThemedBrowseByDatePageComponent,
ThemedBrowseByTitlePageComponent, ThemedBrowseByTitlePageComponent,
ThemedBrowseByTaxonomyPageComponent,
]; ];
@NgModule({ @NgModule({
@@ -29,7 +33,8 @@ const ENTRY_COMPONENTS = [
SharedBrowseByModule, SharedBrowseByModule,
CommonModule, CommonModule,
ComcolModule, ComcolModule,
DsoPageModule DsoPageModule,
FormModule,
], ],
declarations: [ declarations: [
BrowseBySwitcherComponent, BrowseBySwitcherComponent,

View File

@@ -1,20 +1,60 @@
// eslint-disable-next-line max-classes-per-file
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; 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 { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list.model'; import { PaginatedList } from '../data/paginated-list.model';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data';
import { dataService } from '../data/base/data-service.decorator'; 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 { RequestParam } from '../cache/models/request-param.model';
import { SearchData, SearchDataImpl } from '../data/base/search-data'; 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<string>,
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<BrowseDefinition> {
createAndSendGetRequest(href$: string | Observable<string>, useCachedVersionIfAvailable: boolean = true) {
createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable);
}
}
/** /**
* Data service responsible for retrieving browse definitions from the REST server * 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) @dataService(BROWSE_DEFINITION)
export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition>, SearchData<BrowseDefinition> { export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition>, SearchData<BrowseDefinition> {
private findAllData: FindAllDataImpl<BrowseDefinition>; private findAllData: BrowseDefinitionFindAllDataImpl;
private searchData: SearchDataImpl<BrowseDefinition>; private searchData: SearchDataImpl<BrowseDefinition>;
constructor( constructor(
@@ -35,7 +75,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
) { ) {
super('browses', requestService, rdbService, objectCache, halService); super('browses', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); this.findAllData = new BrowseDefinitionFindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
/** /**
@@ -121,5 +161,8 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
); );
} }
createAndSendGetRequest(href$: string | Observable<string>, useCachedVersionIfAvailable: boolean = true) {
createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable);
}
} }

View File

@@ -6,13 +6,15 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { BrowseService } from './browse.service'; import { BrowseService } from './browse.service';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList, getFirstUsedArgumentOfSpyMethod } from '../../shared/testing/utils.test'; import { createPaginatedList, getFirstUsedArgumentOfSpyMethod } from '../../shared/testing/utils.test';
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
import { RequestEntry } from '../data/request-entry.model'; 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', () => { describe('BrowseService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -23,9 +25,9 @@ describe('BrowseService', () => {
const browsesEndpointURL = 'https://rest.api/browses'; const browsesEndpointURL = 'https://rest.api/browses';
const halService: any = new HALEndpointServiceStub(browsesEndpointURL); const halService: any = new HALEndpointServiceStub(browsesEndpointURL);
const browseDefinitions = [ const browseDefinitions = [
Object.assign(new BrowseDefinition(), { Object.assign(new FlatBrowseDefinition(), {
id: 'date', id: 'date',
metadataBrowse: false, browseType: 'flatBrowse',
sortOptions: [ sortOptions: [
{ {
name: 'title', name: 'title',
@@ -50,9 +52,9 @@ describe('BrowseService', () => {
items: { href: 'https://rest.api/discover/browses/dateissued/items' } items: { href: 'https://rest.api/discover/browses/dateissued/items' }
} }
}), }),
Object.assign(new BrowseDefinition(), { Object.assign(new ValueListBrowseDefinition(), {
id: 'author', id: 'author',
metadataBrowse: true, browseType: 'valueList',
sortOptions: [ sortOptions: [
{ {
name: 'title', name: 'title',
@@ -78,7 +80,23 @@ describe('BrowseService', () => {
entries: { href: 'https://rest.api/discover/browses/author/entries' }, entries: { href: 'https://rest.api/discover/browses/author/entries' },
items: { href: 'https://rest.api/discover/browses/author/items' } 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; let browseDefinitionDataService;
@@ -140,7 +158,7 @@ describe('BrowseService', () => {
describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { describe('when getBrowseEntriesFor is called with a valid browse definition id', () => {
it('should call hrefOnlyDataService.findListByHref with the expected href', () => { 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.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe());
scheduler.flush(); scheduler.flush();

View File

@@ -7,6 +7,7 @@ import { PaginatedList } from '../data/paginated-list.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseDefinition } from '../shared/browse-definition.model';
import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model';
import { BrowseEntry } from '../shared/browse-entry.model'; import { BrowseEntry } from '../shared/browse-entry.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
@@ -240,7 +241,12 @@ export class BrowseService {
getPaginatedListPayload(), getPaginatedListPayload(),
map((browseDefinitions: BrowseDefinition[]) => browseDefinitions map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.find((def: BrowseDefinition) => { .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); return isNotEmpty(matchingKeys);
}) })
), ),

View File

@@ -177,6 +177,10 @@ import { IdentifierData } from '../shared/object-list/identifier-data/identifier
import { Subscription } from '../shared/subscriptions/models/subscription.model'; import { Subscription } from '../shared/subscriptions/models/subscription.model';
import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service';
import { ItemRequest } from './shared/item-request.model'; 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 * When not in production, endpoint responses can be mocked for testing purposes
@@ -333,6 +337,10 @@ export const models =
AuthStatus, AuthStatus,
BrowseEntry, BrowseEntry,
BrowseDefinition, BrowseDefinition,
NonHierarchicalBrowseDefinition,
FlatBrowseDefinition,
ValueListBrowseDefinition,
HierarchicalBrowseDefinition,
ClaimedTask, ClaimedTask,
TaskObject, TaskObject,
PoolTask, PoolTask,

View File

@@ -1,13 +1,14 @@
import { TestBed } from '@angular/core/testing';
import { BitstreamDataService } from './bitstream-data.service'; import { BitstreamDataService } from './bitstream-data.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { BitstreamFormatDataService } from './bitstream-format-data.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 { BitstreamFormat } from '../shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; 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 { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; 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 { testSearchDataImplementation } from './base/search-data.spec';
import { testPatchDataImplementation } from './base/patch-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec';
import { testDeleteDataImplementation } from './base/delete-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', () => { describe('BitstreamDataService', () => {
let service: BitstreamDataService; let service: BitstreamDataService;
@@ -25,10 +31,18 @@ describe('BitstreamDataService', () => {
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
const bitstreamFormatHref = 'rest-api/bitstreamformats'; const bitstreamFormatHref = 'rest-api/bitstreamformats';
const bitstream = Object.assign(new Bitstream(), { const bitstream1 = Object.assign(new Bitstream(), {
uuid: 'fake-bitstream', id: 'fake-bitstream1',
uuid: 'fake-bitstream1',
_links: { _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(), { const format = Object.assign(new BitstreamFormat(), {
@@ -50,7 +64,18 @@ describe('BitstreamDataService', () => {
}); });
rdbService = getMockRemoteDataBuildService(); 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', () => { describe('composition', () => {
@@ -62,11 +87,49 @@ describe('BitstreamDataService', () => {
describe('when updating the bitstream\'s format', () => { describe('when updating the bitstream\'s format', () => {
beforeEach(() => { beforeEach(() => {
service.updateFormat(bitstream, format); service.updateFormat(bitstream1, format);
}); });
it('should send a put request', () => { it('should send a put request', () => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PutRequest)); expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PutRequest));
}); });
}); });
describe('removeMultiple', () => {
function mockBuildFromRequestUUIDAndAwait(requestUUID$: string | Observable<string>, callback: (rd?: RemoteData<any>) => Observable<unknown>, ..._linksToFollow: FollowLinkConfig<any>[]): Observable<RemoteData<any>> {
callback();
return;
}
beforeEach(() => {
spyOn(service, 'invalidateByHref');
spyOn(rdbService, 'buildFromRequestUUIDAndAwait').and.callFake((requestUUID$: string | Observable<string>, callback: (rd?: RemoteData<any>) => Observable<unknown>, ...linksToFollow: FollowLinkConfig<any>[]) => 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');
});
});
}); });

View File

@@ -1,7 +1,7 @@
import { HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; 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 { hasValue } from '../../shared/empty.util';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; 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 { BundleDataService } from './bundle-data.service';
import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { buildPaginatedList, PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { PutRequest } from './request.models'; import { PatchRequest, PutRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { BitstreamFormatDataService } from './bitstream-format-data.service'; import { BitstreamFormatDataService } from './bitstream-format-data.service';
import { BitstreamFormat } from '../shared/bitstream-format.model'; 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 { NoContent } from '../shared/NoContent.model';
import { IdentifiableDataService } from './base/identifiable-data.service'; import { IdentifiableDataService } from './base/identifiable-data.service';
import { dataService } from './base/data-service.decorator'; 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 * A service to retrieve {@link Bitstream}s from the REST API
@@ -277,4 +277,34 @@ export class BitstreamDataService extends IdentifiableDataService<Bitstream> imp
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> { deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.deleteByHref(href, copyVirtualMetadata); 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<RemoteData<NoContent>> {
const operations: RemoveOperation[] = bitstreams.map((bitstream: Bitstream) => {
return {
op: 'remove',
path: `/bitstreams/${bitstream.id}`,
};
});
const requestId: string = this.requestService.generateRequestId();
const hrefObs: Observable<string> = 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))));
}
} }

View File

@@ -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<ObjectDomain>(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);
});
});
});

View File

@@ -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<ObjectDomain>(obj): any {
const browseType: string = obj.browseType;
if (obj.type === BROWSE_DEFINITION.value && hasValue(browseType)) {
let serializer: Serializer<BrowseDefinition>;
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.');
}
}
}

View File

@@ -10,6 +10,10 @@ import { hasNoValue, hasValue } from '../../shared/empty.util';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { RestRequest } from './rest-request.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() @Injectable()
export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected toCache = true; protected toCache = true;

View File

@@ -11,6 +11,7 @@ import { TaskResponseParsingService } from '../tasks/task-response-parsing.servi
import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { ContentSourceResponseParsingService } from './content-source-response-parsing.service';
import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model'; import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model';
import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service';
import { BrowseResponseParsingService } from './browse-response-parsing.service';
import { FindListOptions } from './find-list-options.model'; 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<ResponseParsingService> {
return BrowseResponseParsingService;
}
}
export class FindListRequest extends GetRequest { export class FindListRequest extends GetRequest {
constructor( constructor(
uuid: string, uuid: string,

View File

@@ -1,50 +1,16 @@
import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; import { autoserialize } 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 { CacheableObject } from '../cache/cacheable-object.model'; import { CacheableObject } from '../cache/cacheable-object.model';
import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator';
@typedObject /**
export class BrowseDefinition extends CacheableObject { * Base class for BrowseDefinition models
static type = BROWSE_DEFINITION;
/**
* The object type
*/ */
@excludeFromEquals export abstract class BrowseDefinition extends CacheableObject {
@autoserialize
type: ResourceType;
@autoserialize @autoserialize
id: string; id: string;
@autoserialize /**
metadataBrowse: boolean; * Get the render type of the BrowseDefinition model
*/
@autoserialize abstract getRenderType(): string;
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;
};
} }

View File

@@ -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;
}
}

View File

@@ -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');

View File

@@ -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';
}
}

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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');

View File

@@ -223,13 +223,15 @@ export class VocabularyService {
* no valid cached version. Defaults to true * no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re- * @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale * 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 * @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved * {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<VocabularyEntryDetail>>} * @return {Observable<RemoteData<VocabularyEntryDetail>>}
* Return an observable that emits VocabularyEntryDetail object * Return an observable that emits VocabularyEntryDetail object
*/ */
findEntryDetailById(id: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<VocabularyEntryDetail>[]): Observable<RemoteData<VocabularyEntryDetail>> { findEntryDetailById(id: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, constructId: boolean = true, ...linksToFollow: FollowLinkConfig<VocabularyEntryDetail>[]): Observable<RemoteData<VocabularyEntryDetail>> {
const findId = `${name}:${id}`; const findId: string = (constructId ? `${name}:${id}` : id);
return this.vocabularyEntryDetailDataService.findById(findId, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.vocabularyEntryDetailDataService.findById(findId, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }

View File

@@ -25,6 +25,7 @@ import { getMockRequestService } from '../../../shared/mocks/request.service.moc
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../shared/testing/utils.test';
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
import { BitstreamDataServiceStub } from '../../../shared/testing/bitstream-data-service.stub';
let comp: ItemBitstreamsComponent; let comp: ItemBitstreamsComponent;
let fixture: ComponentFixture<ItemBitstreamsComponent>; let fixture: ComponentFixture<ItemBitstreamsComponent>;
@@ -71,7 +72,7 @@ let objectUpdatesService: ObjectUpdatesService;
let router: any; let router: any;
let route: ActivatedRoute; let route: ActivatedRoute;
let notificationsService: NotificationsService; let notificationsService: NotificationsService;
let bitstreamService: BitstreamDataService; let bitstreamService: BitstreamDataServiceStub;
let objectCache: ObjectCacheService; let objectCache: ObjectCacheService;
let requestService: RequestService; let requestService: RequestService;
let searchConfig: SearchConfigurationService; let searchConfig: SearchConfigurationService;
@@ -112,9 +113,7 @@ describe('ItemBitstreamsComponent', () => {
success: successNotification success: successNotification
} }
); );
bitstreamService = jasmine.createSpyObj('bitstreamService', { bitstreamService = new BitstreamDataServiceStub();
delete: jasmine.createSpy('delete')
});
objectCache = jasmine.createSpyObj('objectCache', { objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove') remove: jasmine.createSpy('remove')
}); });
@@ -179,15 +178,16 @@ describe('ItemBitstreamsComponent', () => {
describe('when submit is called', () => { describe('when submit is called', () => {
beforeEach(() => { beforeEach(() => {
spyOn(bitstreamService, 'removeMultiple').and.callThrough();
comp.submit(); comp.submit();
}); });
it('should call delete on the bitstreamService for the marked field', () => { it('should call removeMultiple on the bitstreamService for the marked field', () => {
expect(bitstreamService.delete).toHaveBeenCalledWith(bitstream2.id); expect(bitstreamService.removeMultiple).toHaveBeenCalledWith([bitstream2]);
}); });
it('should not call delete on the bitstreamService for the unmarked field', () => { it('should not call removeMultiple on the bitstreamService for the unmarked field', () => {
expect(bitstreamService.delete).not.toHaveBeenCalledWith(bitstream1.id); expect(bitstreamService.removeMultiple).not.toHaveBeenCalledWith([bitstream1]);
}); });
}); });
@@ -210,7 +210,6 @@ describe('ItemBitstreamsComponent', () => {
comp.dropBitstream(bundle, { comp.dropBitstream(bundle, {
fromIndex: 0, fromIndex: 0,
toIndex: 50, toIndex: 50,
// eslint-disable-next-line no-empty, @typescript-eslint/no-empty-function
finish: () => { finish: () => {
done(); done();
} }

View File

@@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, NgZone, OnDestroy } from '@angular/core'; import { ChangeDetectorRef, Component, NgZone, OnDestroy } from '@angular/core';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { filter, map, switchMap, take } from 'rxjs/operators'; 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 { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -133,20 +133,16 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme
); );
// Send out delete requests for all deleted bitstreams // Send out delete requests for all deleted bitstreams
const removedResponses$ = removedBitstreams$.pipe( const removedResponses$: Observable<RemoteData<NoContent>> = removedBitstreams$.pipe(
take(1), take(1),
switchMap((removedBistreams: Bitstream[]) => { switchMap((removedBitstreams: Bitstream[]) => {
if (isNotEmpty(removedBistreams)) { return this.bitstreamService.removeMultiple(removedBitstreams);
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.delete(bitstream.id)));
} else {
return observableOf(undefined);
}
}) })
); );
// Perform the setup actions from above in order and display notifications // Perform the setup actions from above in order and display notifications
removedResponses$.pipe(take(1)).subscribe((responses: RemoteData<NoContent>[]) => { removedResponses$.subscribe((responses: RemoteData<NoContent>) => {
this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); this.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]);
this.submitting = false; this.submitting = false;
}); });
} }

View File

@@ -3,6 +3,7 @@ import { MetadataValue } from '../../../core/shared/metadata.models';
import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface';
import { BrowseDefinition } from '../../../core/shared/browse-definition.model'; import { BrowseDefinition } from '../../../core/shared/browse-definition.model';
import { hasValue } from '../../../shared/empty.util'; 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. * This component renders the configured 'values' into the ds-metadata-field-wrapper component.
@@ -84,7 +85,7 @@ export class MetadataValuesComponent implements OnChanges {
*/ */
getQueryParams(value) { getQueryParams(value) {
let queryParams = {startsWith: value}; let queryParams = {startsWith: value};
if (this.browseDefinition.metadataBrowse) { if (this.browseDefinition.getRenderType() === VALUE_LIST_BROWSE_DEFINITION.value) {
return {value: value}; return {value: value};
} }
return queryParams; return queryParams;

View File

@@ -34,11 +34,13 @@
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button [ngbTooltip]="getOperationTooltip(entry) | translate" container="body" <button [ngbTooltip]="getOperationTooltip(entry) | translate" container="body"
class="btn btn-outline-primary my-1 col-md" (click)="send(entry)"> class="btn btn-outline-primary my-1 col-md" (click)="send(entry)">
<i [ngClass]="getOperationClass(entry)"></i> <span [ngClass]="getOperationClass(entry)"></span>
<span class="sr-only">{{ getOperationTooltip(entry) | translate }}</span>
</button> </button>
<button [ngbTooltip]="'person.page.orcid.sync-queue.discard' | translate" container="body" <button [ngbTooltip]="'person.page.orcid.sync-queue.discard' | translate" container="body"
class="btn btn-outline-danger my-1 col-md" (click)="discardEntry(entry)"> class="btn btn-outline-danger my-1 col-md" (click)="discardEntry(entry)">
<i class="fas fa-unlink"></i> <span class="fas fa-unlink"></span>
<span class="sr-only">{{ 'person.page.orcid.sync-queue.discard' | translate }}</span>
</button> </button>
</div> </div>
</td> </td>

View File

@@ -16,7 +16,6 @@ import { RouterTestingModule } from '@angular/router/testing';
import { BrowseService } from '../core/browse/browse.service'; import { BrowseService } from '../core/browse/browse.service';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { buildPaginatedList } from '../core/data/paginated-list.model'; 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 { BrowseByDataType } from '../browse-by/browse-by-switcher/browse-by-decorator';
import { Item } from '../core/shared/item.model'; import { Item } from '../core/shared/item.model';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; 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 { provideMockStore } from '@ngrx/store/testing';
import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model';
import { EPersonMock } from '../shared/testing/eperson.mock'; 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 comp: NavbarComponent;
let fixture: ComponentFixture<NavbarComponent>; let fixture: ComponentFixture<NavbarComponent>;
@@ -66,30 +68,35 @@ describe('NavbarComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
browseDefinitions = [ browseDefinitions = [
Object.assign( Object.assign(
new BrowseDefinition(), { new FlatBrowseDefinition(), {
id: 'title', id: 'title',
dataType: BrowseByDataType.Title, dataType: BrowseByDataType.Title,
} }
), ),
Object.assign( Object.assign(
new BrowseDefinition(), { new FlatBrowseDefinition(), {
id: 'dateissued', id: 'dateissued',
dataType: BrowseByDataType.Date, dataType: BrowseByDataType.Date,
metadataKeys: ['dc.date.issued'] metadataKeys: ['dc.date.issued']
} }
), ),
Object.assign( Object.assign(
new BrowseDefinition(), { new ValueListBrowseDefinition(), {
id: 'author', id: 'author',
dataType: BrowseByDataType.Metadata, dataType: BrowseByDataType.Metadata,
} }
), ),
Object.assign( Object.assign(
new BrowseDefinition(), { new ValueListBrowseDefinition(), {
id: 'subject', id: 'subject',
dataType: BrowseByDataType.Metadata, dataType: BrowseByDataType.Metadata,
} }
), ),
Object.assign(
new HierarchicalBrowseDefinition(), {
id: 'srsc',
}
),
]; ];
initialState = { initialState = {
core: { core: {

View File

@@ -50,6 +50,10 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection
if (hasNoValue(id) && hasValue(route.queryParams.scope)) { if (hasNoValue(id) && hasValue(route.queryParams.scope)) {
id = route.queryParams.scope; id = route.queryParams.scope;
} }
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( return this.dSpaceObjectDataService.findById(id, true, false).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
switchMap((dsoRD) => { switchMap((dsoRD) => {
@@ -72,6 +76,7 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection
}) })
); );
} }
}
/** /**
* Return all the menus for a dso based on the route and state * Return all the menus for a dso based on the route and state

View File

@@ -30,8 +30,8 @@ import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/
import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../../../core/shared/page-info.model';
import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component';
import { Vocabulary } from '../../../../../../core/submission/vocabularies/models/vocabulary.model'; 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 { 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. * Component representing a onebox input field.
@@ -222,10 +222,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
map((vocabulary: Vocabulary) => vocabulary.preloadLevel), map((vocabulary: Vocabulary) => vocabulary.preloadLevel),
take(1) take(1)
).subscribe((preloadLevel) => { ).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.vocabularyOptions = this.model.vocabularyOptions;
modalRef.componentInstance.preloadLevel = preloadLevel; modalRef.componentInstance.preloadLevel = preloadLevel;
modalRef.componentInstance.selectedItem = this.currentValue ? this.currentValue : ''; modalRef.componentInstance.selectedItems = this.currentValue ? [this.currentValue.value] : [];
modalRef.result.then((result: VocabularyEntryDetail) => { modalRef.result.then((result: VocabularyEntryDetail) => {
if (result) { if (result) {
this.currentValue = result; this.currentValue = result;

View File

@@ -32,7 +32,7 @@ import { NumberPickerComponent } from './number-picker/number-picker.component';
import { AuthorityConfidenceStateDirective } from './directives/authority-confidence-state.directive'; import { AuthorityConfidenceStateDirective } from './directives/authority-confidence-state.directive';
import { SortablejsModule } from 'ngx-sortablejs'; import { SortablejsModule } from 'ngx-sortablejs';
import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; 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 { FormBuilderService } from './builder/form-builder.service';
import { DsDynamicTypeBindRelationService } from './builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { DsDynamicTypeBindRelationService } from './builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service';
import { FormService } from './form.service'; import { FormService } from './form.service';
@@ -71,7 +71,8 @@ const COMPONENTS = [
ChipsComponent, ChipsComponent,
NumberPickerComponent, NumberPickerComponent,
VocabularyTreeviewComponent, VocabularyTreeviewComponent,
ThemedExternalSourceEntryImportModalComponent VocabularyTreeviewModalComponent,
ThemedExternalSourceEntryImportModalComponent,
]; ];
const DIRECTIVES = [ const DIRECTIVES = [
@@ -105,7 +106,6 @@ const DIRECTIVES = [
provide: DYNAMIC_FORM_CONTROL_MAP_FN, provide: DYNAMIC_FORM_CONTROL_MAP_FN,
useValue: dsDynamicFormControlMapFn useValue: dsDynamicFormControlMapFn
}, },
VocabularyTreeviewService,
DynamicFormLayoutService, DynamicFormLayoutService,
DynamicFormService, DynamicFormService,
DynamicFormValidationService, DynamicFormValidationService,

View File

@@ -0,0 +1,16 @@
<div class="modal-header">
<h4 class="modal-title">{{'vocabulary-treeview.header' | translate}}</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="p-3">
<ds-vocabulary-treeview [vocabularyOptions]="vocabularyOptions"
[preloadLevel]="preloadLevel"
[selectedItems]="selectedItems"
[multiSelect]="multiSelect"
(select)="onSelect($event)">
</ds-vocabulary-treeview>
</div>
</div>

View File

@@ -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<VocabularyTreeviewModalComponent>;
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();
});
});

View File

@@ -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);
}
}

View File

@@ -21,7 +21,8 @@ export class TreeviewNode {
public pageInfo: PageInfo = new PageInfo(), public pageInfo: PageInfo = new PageInfo(),
public loadMoreParentItem: VocabularyEntryDetail | null = null, public loadMoreParentItem: VocabularyEntryDetail | null = null,
public isSearchNode = false, public isSearchNode = false,
public isInInitValueHierarchy = false) { public isInInitValueHierarchy = false,
public isSelected = false) {
} }
updatePageInfo(pageInfo: PageInfo) { updatePageInfo(pageInfo: PageInfo) {
@@ -38,7 +39,8 @@ export class TreeviewFlatNode {
public pageInfo: PageInfo = new PageInfo(), public pageInfo: PageInfo = new PageInfo(),
public loadMoreParentItem: VocabularyEntryDetail | null = null, public loadMoreParentItem: VocabularyEntryDetail | null = null,
public isSearchNode = false, public isSearchNode = false,
public isInInitValueHierarchy = false) { public isInInitValueHierarchy = false,
public isSelected = false) {
} }
} }

View File

@@ -1,13 +1,5 @@
<div class="modal-header"> <ds-alert *ngIf="description | async" [content]="description | async" [type]="'alert-info'"></ds-alert>
<h4 class="modal-title">{{'vocabulary-treeview.header' | translate}}</h4> <div class="treeview-header row">
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="p-3">
<ds-alert *ngIf="description | async" [content]="description | async" [type]="'alert-info'"></ds-alert>
<div class="treeview-header row">
<div class="col-12"> <div class="col-12">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" [(ngModel)]="searchText" (keyup.enter)="search()"> <input type="text" class="form-control" [(ngModel)]="searchText" (keyup.enter)="search()">
@@ -21,8 +13,8 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="treeview-container"> <div class="treeview-container">
<ds-themed-loading *ngIf="loading | async" [showMessage]="false"></ds-themed-loading> <ds-themed-loading *ngIf="loading | async" [showMessage]="false"></ds-themed-loading>
<h4 *ngIf="!(loading | async) && dataSource.data.length === 0" class="text-center text-muted mt-4" > <h4 *ngIf="!(loading | async) && dataSource.data.length === 0" class="text-center text-muted mt-4" >
<span>{{'vocabulary-treeview.search.no-result' | translate}}</span> <span>{{'vocabulary-treeview.search.no-result' | translate}}</span>
@@ -31,15 +23,31 @@
<!-- Leaf node --> <!-- Leaf node -->
<cdk-tree-node *cdkTreeNodeDef="let node" cdkTreeNodePadding class="d-flex"> <cdk-tree-node *cdkTreeNodeDef="let node" cdkTreeNodePadding class="d-flex">
<button type="button" class="btn btn-default" cdkTreeNodeToggle> <button type="button" class="btn btn-default" cdkTreeNodeToggle>
<span class="fas fa-angle-right fa-2x invisible" aria-hidden="true"></span> <span class="fas fa-angle-right invisible" aria-hidden="true"></span>
</button> </button>
<button class="btn btn-outline-link btn-sm text-left" <label *ngIf="multiSelect" class="d-flex align-items-center m-0 p-0 form-check"
[class.text-success]="node.item?.value === selectedItem?.value" [class.text-success]="node.isSelected"
[ngbTooltip]="node.item?.otherInformation?.note"
[openDelay]="500"
container="body"
>
<input class="mr-2" type="checkbox"
[disabled]="!node.item?.selectable"
[(ngModel)]="node.isSelected"
[checked]="node.isSelected"
(change)="onSelect(node.item)"
>
<span>{{node.item.display}}</span>
</label>
<button *ngIf="!multiSelect" class="btn btn-outline-link btn-sm text-left"
[class.text-success]="node.isSelected"
[disabled]="!node.item?.selectable" [disabled]="!node.item?.selectable"
[ngbTooltip]="node.item?.otherInformation?.note" [ngbTooltip]="node.item?.otherInformation?.note"
[openDelay]="500" [openDelay]="500"
container="body" container="body"
(click)="onSelect(node.item)">{{node.item.display}}</button> (click)="onSelect(node.item)">
<span>{{node.item.display}}</span>
</button>
</cdk-tree-node> </cdk-tree-node>
<!-- expandable node --> <!-- expandable node -->
@@ -47,17 +55,32 @@
<button type="button" class="btn btn-default" cdkTreeNodeToggle <button type="button" class="btn btn-default" cdkTreeNodeToggle
[attr.aria-label]="'toggle ' + node.name" [attr.aria-label]="'toggle ' + node.name"
(click)="loadChildren(node)"> (click)="loadChildren(node)">
<span class="fas {{treeControl.isExpanded(node) ? 'fa-angle-down' : 'fa-angle-right'}} fa-2x" <span class="fas {{treeControl.isExpanded(node) ? 'fa-angle-down' : 'fa-angle-right'}}"
aria-hidden="true"></span> aria-hidden="true"></span>
</button> </button>
<button class="btn btn-outline-link btn-sm text-left" <label *ngIf="multiSelect" class="d-flex align-items-center m-0 p-0 form-check"
[class.text-success]="node.item?.value === selectedItem?.value" [class.text-success]="node.isSelected"
[ngbTooltip]="node.item?.otherInformation?.note"
[openDelay]="500"
container="body">
<input class="mr-2" type="checkbox"
[disabled]="!node.item?.selectable"
[(ngModel)]="node.isSelected"
[checked]="node.isSelected"
(change)="onSelect(node.item)"
>
<span>{{node.item.display}}</span>
</label>
<button *ngIf="!multiSelect" class="btn btn-outline-link btn-sm text-left"
[class.text-success]="node.isSelected"
[disabled]="!node.item?.selectable" [disabled]="!node.item?.selectable"
[ngbTooltip]="node.item?.otherInformation?.note" [ngbTooltip]="node.item?.otherInformation?.note"
[openDelay]="500" [openDelay]="500"
container="body" container="body"
(click)="onSelect(node.item)">{{node.item.display}}</button> (click)="onSelect(node.item)">
<span>{{node.item.display}}</span>
</button>
</cdk-tree-node> </cdk-tree-node>
<cdk-tree-node *cdkTreeNodeDef="let node; when: isLoadMore" cdkTreeNodePadding> <cdk-tree-node *cdkTreeNodeDef="let node; when: isLoadMore" cdkTreeNodePadding>
@@ -72,6 +95,4 @@
</button> </button>
</cdk-tree-node> </cdk-tree-node>
</cdk-tree> </cdk-tree>
</div>
</div>
</div> </div>

View File

@@ -5,3 +5,7 @@
cdk-tree .btn:focus { cdk-tree .btn:focus {
box-shadow: none !important; box-shadow: none !important;
} }
label {
cursor: pointer;
}

View File

@@ -12,7 +12,7 @@ import { createTestComponent } from '../../testing/utils.test';
import { VocabularyTreeviewComponent } from './vocabulary-treeview.component'; import { VocabularyTreeviewComponent } from './vocabulary-treeview.component';
import { VocabularyTreeviewService } from './vocabulary-treeview.service'; import { VocabularyTreeviewService } from './vocabulary-treeview.service';
import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
import { TreeviewFlatNode } from './vocabulary-treeview-node.model'; import { TreeviewFlatNode, TreeviewNode } from './vocabulary-treeview-node.model';
import { FormFieldMetadataValueObject } from '../builder/models/form-field-metadata-value.model'; import { FormFieldMetadataValueObject } from '../builder/models/form-field-metadata-value.model';
import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model';
import { PageInfo } from '../../../core/shared/page-info.model'; import { PageInfo } from '../../../core/shared/page-info.model';
@@ -20,6 +20,8 @@ import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vo
import { AuthTokenInfo } from '../../../core/auth/models/auth-token-info.model'; import { AuthTokenInfo } from '../../../core/auth/models/auth-token-info.model';
import { authReducer } from '../../../core/auth/auth.reducer'; import { authReducer } from '../../../core/auth/auth.reducer';
import { storeModuleConfig } from '../../../app.reducer'; import { storeModuleConfig } from '../../../app.reducer';
import { By } from '@angular/platform-browser';
import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service';
describe('VocabularyTreeviewComponent test suite', () => { describe('VocabularyTreeviewComponent test suite', () => {
@@ -27,6 +29,7 @@ describe('VocabularyTreeviewComponent test suite', () => {
let compAsAny: any; let compAsAny: any;
let fixture: ComponentFixture<VocabularyTreeviewComponent>; let fixture: ComponentFixture<VocabularyTreeviewComponent>;
let initialState; let initialState;
let de;
const item = new VocabularyEntryDetail(); const item = new VocabularyEntryDetail();
item.id = 'node1'; item.id = 'node1';
@@ -47,6 +50,14 @@ describe('VocabularyTreeviewComponent test suite', () => {
restoreNodes: jasmine.createSpy('restoreNodes'), restoreNodes: jasmine.createSpy('restoreNodes'),
cleanTree: jasmine.createSpy('cleanTree'), cleanTree: jasmine.createSpy('cleanTree'),
}); });
const vocabularyServiceStub = jasmine.createSpyObj('VocabularyService', {
getVocabularyEntriesByValue: jasmine.createSpy('getVocabularyEntriesByValue'),
getEntryDetailParent: jasmine.createSpy('getEntryDetailParent'),
findEntryDetailById: jasmine.createSpy('findEntryDetailById'),
searchTopEntries: jasmine.createSpy('searchTopEntries'),
getEntryDetailChildren: jasmine.createSpy('getEntryDetailChildren'),
clearSearchTopRequests: jasmine.createSpy('clearSearchTopRequests')
});
initialState = { initialState = {
core: { core: {
@@ -75,6 +86,7 @@ describe('VocabularyTreeviewComponent test suite', () => {
], ],
providers: [ providers: [
{ provide: VocabularyTreeviewService, useValue: vocabularyTreeviewServiceStub }, { provide: VocabularyTreeviewService, useValue: vocabularyTreeviewServiceStub },
{ provide: VocabularyService, useValue: vocabularyServiceStub },
{ provide: NgbActiveModal, useValue: modalStub }, { provide: NgbActiveModal, useValue: modalStub },
provideMockStore({ initialState }), provideMockStore({ initialState }),
ChangeDetectorRef, ChangeDetectorRef,
@@ -117,13 +129,7 @@ describe('VocabularyTreeviewComponent test suite', () => {
comp = fixture.componentInstance; comp = fixture.componentInstance;
compAsAny = comp; compAsAny = comp;
comp.vocabularyOptions = vocabularyOptions; comp.vocabularyOptions = vocabularyOptions;
comp.selectedItem = null; comp.selectedItems = [];
});
afterEach(() => {
fixture.destroy();
comp = null;
compAsAny = null;
}); });
it('should should init component properly', () => { it('should should init component properly', () => {
@@ -138,10 +144,10 @@ describe('VocabularyTreeviewComponent test suite', () => {
currentValue.otherInformation = { currentValue.otherInformation = {
id: 'entryID' id: 'entryID'
}; };
comp.selectedItem = currentValue; comp.selectedItems = [currentValue.value];
fixture.detectChanges(); fixture.detectChanges();
expect(comp.dataSource.data).toEqual([]); expect(comp.dataSource.data).toEqual([]);
expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), null); expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), ['testValue'], null);
}); });
it('should should init component properly with init value as VocabularyEntry', () => { it('should should init component properly with init value as VocabularyEntry', () => {
@@ -150,30 +156,30 @@ describe('VocabularyTreeviewComponent test suite', () => {
currentValue.otherInformation = { currentValue.otherInformation = {
id: 'entryID' id: 'entryID'
}; };
comp.selectedItem = currentValue; comp.selectedItems = [currentValue.value];
fixture.detectChanges(); fixture.detectChanges();
expect(comp.dataSource.data).toEqual([]); expect(comp.dataSource.data).toEqual([]);
expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), null); expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), ['testValue'], null);
}); });
it('should call loadMore function', () => { it('should call loadMore function', () => {
comp.loadMore(item); comp.loadMore(item);
fixture.detectChanges(); fixture.detectChanges();
expect(vocabularyTreeviewServiceStub.loadMore).toHaveBeenCalledWith(item); expect(vocabularyTreeviewServiceStub.loadMore).toHaveBeenCalledWith(item, []);
}); });
it('should call loadMoreRoot function', () => { it('should call loadMoreRoot function', () => {
const node = new TreeviewFlatNode(item); const node = new TreeviewFlatNode(item);
comp.loadMoreRoot(node); comp.loadMoreRoot(node);
fixture.detectChanges(); fixture.detectChanges();
expect(vocabularyTreeviewServiceStub.loadMoreRoot).toHaveBeenCalledWith(node); expect(vocabularyTreeviewServiceStub.loadMoreRoot).toHaveBeenCalledWith(node, []);
}); });
it('should call loadChildren function', () => { it('should call loadChildren function', () => {
const node = new TreeviewFlatNode(item); const node = new TreeviewFlatNode(item);
comp.loadChildren(node); comp.loadChildren(node);
fixture.detectChanges(); fixture.detectChanges();
expect(vocabularyTreeviewServiceStub.loadMore).toHaveBeenCalledWith(node.item, true); expect(vocabularyTreeviewServiceStub.loadMore).toHaveBeenCalledWith(node.item, [], true);
}); });
it('should emit select event', () => { it('should emit select event', () => {
@@ -188,7 +194,7 @@ describe('VocabularyTreeviewComponent test suite', () => {
comp.nodeMap.set('test', new TreeviewFlatNode(item)); comp.nodeMap.set('test', new TreeviewFlatNode(item));
comp.search(); comp.search();
fixture.detectChanges(); fixture.detectChanges();
expect(vocabularyTreeviewServiceStub.searchByQuery).toHaveBeenCalledWith('test search'); expect(vocabularyTreeviewServiceStub.searchByQuery).toHaveBeenCalledWith('test search', []);
expect(comp.storedNodeMap).toEqual(nodeMap); expect(comp.storedNodeMap).toEqual(nodeMap);
expect(comp.nodeMap).toEqual(emptyNodeMap); expect(comp.nodeMap).toEqual(emptyNodeMap);
}); });
@@ -199,7 +205,7 @@ describe('VocabularyTreeviewComponent test suite', () => {
comp.storedNodeMap.set('test', new TreeviewFlatNode(item2)); comp.storedNodeMap.set('test', new TreeviewFlatNode(item2));
comp.search(); comp.search();
fixture.detectChanges(); fixture.detectChanges();
expect(vocabularyTreeviewServiceStub.searchByQuery).toHaveBeenCalledWith('test search'); expect(vocabularyTreeviewServiceStub.searchByQuery).toHaveBeenCalledWith('test search', []);
expect(comp.storedNodeMap).toEqual(storedNodeMap); expect(comp.storedNodeMap).toEqual(storedNodeMap);
expect(comp.nodeMap).toEqual(emptyNodeMap); expect(comp.nodeMap).toEqual(emptyNodeMap);
}); });
@@ -229,6 +235,50 @@ describe('VocabularyTreeviewComponent test suite', () => {
expect(vocabularyTreeviewServiceStub.cleanTree).toHaveBeenCalled(); expect(vocabularyTreeviewServiceStub.cleanTree).toHaveBeenCalled();
}); });
}); });
describe('', () => {
beforeEach(() => {
vocabularyTreeviewServiceStub.getData.and.returnValue(observableOf([
{
'item': {
'id': 'srsc:SCB11',
'display': 'HUMANITIES and RELIGION'
}
} as TreeviewNode,
{
'item': {
'id': 'srsc:SCB12',
'display': 'LAW/JURISPRUDENCE'
}
} as TreeviewNode,
{
'item': {
'id': 'srsc:SCB13',
'display': 'SOCIAL SCIENCES'
}
} as TreeviewNode,
]));
fixture = TestBed.createComponent(VocabularyTreeviewComponent);
comp = fixture.componentInstance;
compAsAny = comp;
comp.vocabularyOptions = vocabularyOptions;
comp.selectedItems = [];
de = fixture.debugElement;
});
it('should not display checkboxes by default', async () => {
fixture.detectChanges();
expect(de.query(By.css('input[type=checkbox]'))).toBeNull();
expect(de.queryAll(By.css('cdk-tree-node')).length).toEqual(3);
});
it('should display checkboxes if multiSelect is true', async () => {
comp.multiSelect = true;
fixture.detectChanges();
expect(de.queryAll(By.css('input[type=checkbox]')).length).toEqual(3);
expect(de.queryAll(By.css('cdk-tree-node')).length).toEqual(3);
});
});
}); });
// declare a test component // declare a test component

View File

@@ -1,7 +1,6 @@
import { FlatTreeControl } from '@angular/cdk/tree'; import { FlatTreeControl } from '@angular/cdk/tree';
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -18,9 +17,11 @@ import { VocabularyTreeFlattener } from './vocabulary-tree-flattener';
import { VocabularyTreeFlatDataSource } from './vocabulary-tree-flat-data-source'; import { VocabularyTreeFlatDataSource } from './vocabulary-tree-flat-data-source';
import { CoreState } from '../../../core/core-state.model'; import { CoreState } from '../../../core/core-state.model';
import { lowerCase } from 'lodash/string'; import { lowerCase } from 'lodash/string';
import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
/** /**
* Component that show a hierarchical vocabulary in a tree view * Component that shows a hierarchical vocabulary in a tree view
*/ */
@Component({ @Component({
selector: 'ds-vocabulary-treeview', selector: 'ds-vocabulary-treeview',
@@ -40,9 +41,14 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
@Input() preloadLevel = 2; @Input() preloadLevel = 2;
/** /**
* The vocabulary entry already selected, if any * The vocabulary entries already selected, if any
*/ */
@Input() selectedItem: any = null; @Input() selectedItems: string[] = [];
/**
* Whether to allow selecting multiple values with checkboxes
*/
@Input() multiSelect = false;
/** /**
* Contain a descriptive message for this vocabulary retrieved from i18n files * Contain a descriptive message for this vocabulary retrieved from i18n files
@@ -90,6 +96,12 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
*/ */
@Output() select: EventEmitter<VocabularyEntryDetail> = new EventEmitter<VocabularyEntryDetail>(null); @Output() select: EventEmitter<VocabularyEntryDetail> = new EventEmitter<VocabularyEntryDetail>(null);
/**
* An event fired when a vocabulary entry is deselected.
* Event's payload equals to {@link VocabularyEntryDetail} deselected.
*/
@Output() deselect: EventEmitter<VocabularyEntryDetail> = new EventEmitter<VocabularyEntryDetail>(null);
/** /**
* A boolean representing if user is authenticated * A boolean representing if user is authenticated
*/ */
@@ -103,14 +115,14 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
/** /**
* Initialize instance variables * Initialize instance variables
* *
* @param {NgbActiveModal} activeModal
* @param {VocabularyTreeviewService} vocabularyTreeviewService * @param {VocabularyTreeviewService} vocabularyTreeviewService
* @param {vocabularyService} vocabularyService
* @param {Store<CoreState>} store * @param {Store<CoreState>} store
* @param {TranslateService} translate * @param {TranslateService} translate
*/ */
constructor( constructor(
public activeModal: NgbActiveModal,
private vocabularyTreeviewService: VocabularyTreeviewService, private vocabularyTreeviewService: VocabularyTreeviewService,
private vocabularyService: VocabularyService,
private store: Store<CoreState>, private store: Store<CoreState>,
private translate: TranslateService private translate: TranslateService
) { ) {
@@ -148,7 +160,8 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
node.pageInfo, node.pageInfo,
node.loadMoreParentItem, node.loadMoreParentItem,
node.isSearchNode, node.isSearchNode,
node.isInInitValueHierarchy node.isInInitValueHierarchy,
node.isSelected
); );
this.nodeMap.set(node.item.id, newNode); this.nodeMap.set(node.item.id, newNode);
@@ -211,7 +224,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
this.loading = this.vocabularyTreeviewService.isLoading(); this.loading = this.vocabularyTreeviewService.isLoading();
this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), null); this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), this.selectedItems, null);
} }
/** /**
@@ -219,7 +232,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
* @param item The VocabularyEntryDetail for which to load more nodes * @param item The VocabularyEntryDetail for which to load more nodes
*/ */
loadMore(item: VocabularyEntryDetail) { loadMore(item: VocabularyEntryDetail) {
this.vocabularyTreeviewService.loadMore(item); this.vocabularyTreeviewService.loadMore(item, this.selectedItems);
} }
/** /**
@@ -227,7 +240,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
* @param node The TreeviewFlatNode for which to load more nodes * @param node The TreeviewFlatNode for which to load more nodes
*/ */
loadMoreRoot(node: TreeviewFlatNode) { loadMoreRoot(node: TreeviewFlatNode) {
this.vocabularyTreeviewService.loadMoreRoot(node); this.vocabularyTreeviewService.loadMoreRoot(node, this.selectedItems);
} }
/** /**
@@ -235,16 +248,20 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
* @param node The TreeviewFlatNode for which to load children nodes * @param node The TreeviewFlatNode for which to load children nodes
*/ */
loadChildren(node: TreeviewFlatNode) { loadChildren(node: TreeviewFlatNode) {
this.vocabularyTreeviewService.loadMore(node.item, true); this.vocabularyTreeviewService.loadMore(node.item, this.selectedItems, true);
} }
/** /**
* Method called on entry select * Method called on entry select/deselect
* Emit a new select Event
*/ */
onSelect(item: VocabularyEntryDetail) { onSelect(item: VocabularyEntryDetail) {
if (!this.selectedItems.includes(item.id)) {
this.selectedItems.push(item.id);
this.select.emit(item); this.select.emit(item);
this.activeModal.close(item); } else {
this.selectedItems = this.selectedItems.filter((detail: string) => { return detail !== item.id; });
this.deselect.emit(item);
}
} }
/** /**
@@ -256,7 +273,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
this.storedNodeMap = this.nodeMap; this.storedNodeMap = this.nodeMap;
} }
this.nodeMap = new Map<string, TreeviewFlatNode>(); this.nodeMap = new Map<string, TreeviewFlatNode>();
this.vocabularyTreeviewService.searchByQuery(this.searchText); this.vocabularyTreeviewService.searchByQuery(this.searchText, this.selectedItems);
} }
} }
@@ -271,13 +288,22 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit {
* Reset tree resulting from a previous search * Reset tree resulting from a previous search
*/ */
reset() { reset() {
this.searchText = '';
for (const item of this.selectedItems) {
this.subs.push(this.vocabularyService.findEntryDetailById(item, this.vocabularyOptions.name, true, true, false).pipe(
getFirstSucceededRemoteDataPayload(),
).subscribe((detail: VocabularyEntryDetail) => {
this.deselect.emit(detail);
}));
this.nodeMap.get(item).isSelected = false;
}
this.selectedItems = [];
if (isNotEmpty(this.storedNodeMap)) { if (isNotEmpty(this.storedNodeMap)) {
this.nodeMap = this.storedNodeMap; this.nodeMap = this.storedNodeMap;
this.storedNodeMap = new Map<string, TreeviewFlatNode>(); this.storedNodeMap = new Map<string, TreeviewFlatNode>();
this.vocabularyTreeviewService.restoreNodes(); this.vocabularyTreeviewService.restoreNodes();
} }
this.searchText = '';
} }
/** /**

View File

@@ -192,7 +192,7 @@ describe('VocabularyTreeviewService test suite', () => {
a: createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, [item, item2, item3])) a: createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, [item, item2, item3]))
})); }));
scheduler.schedule(() => service.initialize(vocabularyOptions, pageInfo)); scheduler.schedule(() => service.initialize(vocabularyOptions, pageInfo, []));
scheduler.flush(); scheduler.flush();
expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name); expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name);
@@ -214,7 +214,7 @@ describe('VocabularyTreeviewService test suite', () => {
b: createSuccessfulRemoteDataObject(item) b: createSuccessfulRemoteDataObject(item)
}) })
); );
scheduler.schedule(() => service.initialize(vocabularyOptions, pageInfo, 'root2')); scheduler.schedule(() => service.initialize(vocabularyOptions, pageInfo, [], 'root2'));
scheduler.flush(); scheduler.flush();
expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name); expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name);
@@ -233,11 +233,11 @@ describe('VocabularyTreeviewService test suite', () => {
describe('loadMoreRoot', () => { describe('loadMoreRoot', () => {
it('should call retrieveTopNodes properly', () => { it('should call retrieveTopNodes properly', () => {
spyOn(serviceAsAny, 'retrieveTopNodes'); spyOn(serviceAsAny, 'retrieveTopNodes');
service.initialize(vocabularyOptions, new PageInfo()); service.initialize(vocabularyOptions, new PageInfo(), []);
serviceAsAny.dataChange.next(treeNodeListWithLoadMoreRoot); serviceAsAny.dataChange.next(treeNodeListWithLoadMoreRoot);
service.loadMoreRoot(loadMoreRootFlatNode); service.loadMoreRoot(loadMoreRootFlatNode, []);
expect(serviceAsAny.retrieveTopNodes).toHaveBeenCalledWith(loadMoreRootFlatNode.pageInfo, treeNodeList); expect(serviceAsAny.retrieveTopNodes).toHaveBeenCalledWith(loadMoreRootFlatNode.pageInfo, treeNodeList, []);
}); });
}); });
@@ -263,7 +263,7 @@ describe('VocabularyTreeviewService test suite', () => {
serviceAsAny.nodeMap = nodeMapWithChildren; serviceAsAny.nodeMap = nodeMapWithChildren;
treeNodeListWithChildren.push(new TreeviewNode(child2, false, new PageInfo(), item)); treeNodeListWithChildren.push(new TreeviewNode(child2, false, new PageInfo(), item));
scheduler.schedule(() => service.loadMore(item)); scheduler.schedule(() => service.loadMore(item, []));
scheduler.flush(); scheduler.flush();
expect(serviceAsAny.dataChange.value).toEqual(treeNodeListWithChildren); expect(serviceAsAny.dataChange.value).toEqual(treeNodeListWithChildren);
@@ -285,7 +285,7 @@ describe('VocabularyTreeviewService test suite', () => {
treeNodeListWithChildren.push(childNode2); treeNodeListWithChildren.push(childNode2);
treeNodeListWithChildren.push(loadMoreNode); treeNodeListWithChildren.push(loadMoreNode);
scheduler.schedule(() => service.loadMore(item)); scheduler.schedule(() => service.loadMore(item, []));
scheduler.flush(); scheduler.flush();
expect(serviceAsAny.dataChange.value).toEqual(treeNodeListWithChildren); expect(serviceAsAny.dataChange.value).toEqual(treeNodeListWithChildren);
@@ -319,7 +319,7 @@ describe('VocabularyTreeviewService test suite', () => {
); );
vocabularyOptions.query = 'root1-child1-child1'; vocabularyOptions.query = 'root1-child1-child1';
scheduler.schedule(() => service.searchByQuery(vocabularyOptions)); scheduler.schedule(() => service.searchByQuery(vocabularyOptions, []));
scheduler.flush(); scheduler.flush();
// We can't check the tree by comparing root TreeviewNodes directly in this particular test; // We can't check the tree by comparing root TreeviewNodes directly in this particular test;

View File

@@ -25,7 +25,9 @@ import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/mod
/** /**
* A service that provides methods to deal with vocabulary tree * A service that provides methods to deal with vocabulary tree
*/ */
@Injectable() @Injectable({
providedIn: 'root'
})
export class VocabularyTreeviewService { export class VocabularyTreeviewService {
/** /**
@@ -101,21 +103,22 @@ export class VocabularyTreeviewService {
* *
* @param options The {@link VocabularyOptions} object * @param options The {@link VocabularyOptions} object
* @param pageInfo The {@link PageInfo} object * @param pageInfo The {@link PageInfo} object
* @param selectedItems The currently selected items
* @param initValueId The entry id of the node to mark as selected, if any * @param initValueId The entry id of the node to mark as selected, if any
*/ */
initialize(options: VocabularyOptions, pageInfo: PageInfo, initValueId?: string): void { initialize(options: VocabularyOptions, pageInfo: PageInfo, selectedItems: string[], initValueId?: string): void {
this.loading.next(true); this.loading.next(true);
this.vocabularyOptions = options; this.vocabularyOptions = options;
this.vocabularyName = options.name; this.vocabularyName = options.name;
this.pageInfo = pageInfo; this.pageInfo = pageInfo;
if (isNotEmpty(initValueId)) { if (isNotEmpty(initValueId)) {
this.getNodeHierarchyById(initValueId) this.getNodeHierarchyById(initValueId, selectedItems)
.subscribe((hierarchy: string[]) => { .subscribe((hierarchy: string[]) => {
this.initValueHierarchy = hierarchy; this.initValueHierarchy = hierarchy;
this.retrieveTopNodes(pageInfo, []); this.retrieveTopNodes(pageInfo, [], selectedItems);
}); });
} else { } else {
this.retrieveTopNodes(pageInfo, []); this.retrieveTopNodes(pageInfo, [], selectedItems);
} }
} }
@@ -129,19 +132,21 @@ export class VocabularyTreeviewService {
/** /**
* Expand the root node whose children are not loaded * Expand the root node whose children are not loaded
* @param node The root node * @param node The root node
* @param selectedItems The currently selected items
*/ */
loadMoreRoot(node: TreeviewFlatNode) { loadMoreRoot(node: TreeviewFlatNode, selectedItems: string[]) {
const nodes = this.dataChange.value; const nodes = this.dataChange.value;
nodes.pop(); nodes.pop();
this.retrieveTopNodes(node.pageInfo, nodes); this.retrieveTopNodes(node.pageInfo, nodes, selectedItems);
} }
/** /**
* Expand a node whose children are not loaded * Expand a node whose children are not loaded
* @param item * @param item
* @param selectedItems
* @param onlyFirstTime * @param onlyFirstTime
*/ */
loadMore(item: VocabularyEntryDetail, onlyFirstTime = false) { loadMore(item: VocabularyEntryDetail, selectedItems: string[], onlyFirstTime = false) {
if (!this.nodeMap.has(item.otherInformation.id)) { if (!this.nodeMap.has(item.otherInformation.id)) {
return; return;
} }
@@ -154,7 +159,7 @@ export class VocabularyTreeviewService {
return; return;
} }
const newNodes: TreeviewNode[] = list.page.map((entry) => this._generateNode(entry)); const newNodes: TreeviewNode[] = list.page.map((entry) => this._generateNode(entry, selectedItems));
children.push(...newNodes); children.push(...newNodes);
if ((list.pageInfo.currentPage + 1) <= list.pageInfo.totalPages) { if ((list.pageInfo.currentPage + 1) <= list.pageInfo.totalPages) {
@@ -183,7 +188,7 @@ export class VocabularyTreeviewService {
/** /**
* Perform a search operation by query * Perform a search operation by query
*/ */
searchByQuery(query: string) { searchByQuery(query: string, selectedItems: string[]) {
this.loading.next(true); this.loading.next(true);
if (isEmpty(this.storedNodes)) { if (isEmpty(this.storedNodes)) {
this.storedNodes = this.dataChange.value; this.storedNodes = this.dataChange.value;
@@ -200,7 +205,7 @@ export class VocabularyTreeviewService {
getFirstSucceededRemoteDataPayload() getFirstSucceededRemoteDataPayload()
) )
), ),
mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry)), mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, selectedItems)),
scan((acc: TreeviewNode[], value: TreeviewNode) => { scan((acc: TreeviewNode[], value: TreeviewNode) => {
if (isEmpty(value) || findIndex(acc, (node) => node.item.otherInformation.id === value.item.otherInformation.id) !== -1) { if (isEmpty(value) || findIndex(acc, (node) => node.item.otherInformation.id === value.item.otherInformation.id) !== -1) {
return acc; return acc;
@@ -231,11 +236,12 @@ export class VocabularyTreeviewService {
* Generate a {@link TreeviewNode} object from vocabulary entry * Generate a {@link TreeviewNode} object from vocabulary entry
* *
* @param entry The vocabulary entry detail * @param entry The vocabulary entry detail
* @param selectedItems An array containing the currently selected items
* @param isSearchNode A Boolean representing if given entry is the result of a search * @param isSearchNode A Boolean representing if given entry is the result of a search
* @param toStore A Boolean representing if the node created is to store or not * @param toStore A Boolean representing if the node created is to store or not
* @return TreeviewNode * @return TreeviewNode
*/ */
private _generateNode(entry: VocabularyEntryDetail, isSearchNode = false, toStore = true): TreeviewNode { private _generateNode(entry: VocabularyEntryDetail, selectedItems: string[], isSearchNode = false, toStore = true): TreeviewNode {
const entryId = entry.otherInformation.id; const entryId = entry.otherInformation.id;
if (this.nodeMap.has(entryId)) { if (this.nodeMap.has(entryId)) {
return this.nodeMap.get(entryId)!; return this.nodeMap.get(entryId)!;
@@ -243,13 +249,15 @@ export class VocabularyTreeviewService {
const hasChildren = entry.hasOtherInformation() && (entry.otherInformation as any)!.hasChildren === 'true'; const hasChildren = entry.hasOtherInformation() && (entry.otherInformation as any)!.hasChildren === 'true';
const pageInfo: PageInfo = this.pageInfo; const pageInfo: PageInfo = this.pageInfo;
const isInInitValueHierarchy = this.initValueHierarchy.includes(entryId); const isInInitValueHierarchy = this.initValueHierarchy.includes(entryId);
const isSelected: boolean = selectedItems.some(() => selectedItems.includes(entry.id));
const result = new TreeviewNode( const result = new TreeviewNode(
entry, entry,
hasChildren, hasChildren,
pageInfo, pageInfo,
null, null,
isSearchNode, isSearchNode,
isInInitValueHierarchy); isInInitValueHierarchy,
isSelected);
if (toStore) { if (toStore) {
this.nodeMap.set(entryId, result); this.nodeMap.set(entryId, result);
@@ -260,12 +268,13 @@ export class VocabularyTreeviewService {
/** /**
* Return the node Hierarchy by a given node's id * Return the node Hierarchy by a given node's id
* @param id The node id * @param id The node id
* @param selectedItems The currently selected items
* @return Observable<string[]> * @return Observable<string[]>
*/ */
private getNodeHierarchyById(id: string): Observable<string[]> { private getNodeHierarchyById(id: string, selectedItems: string[]): Observable<string[]> {
return this.getById(id).pipe( return this.getById(id).pipe(
mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, [], false)), mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, selectedItems,[], false)),
map((node: TreeviewNode) => this.getNodeHierarchyIds(node)) map((node: TreeviewNode) => this.getNodeHierarchyIds(node, selectedItems))
); );
} }
@@ -306,13 +315,14 @@ export class VocabularyTreeviewService {
* Retrieve the top level vocabulary entries * Retrieve the top level vocabulary entries
* @param pageInfo The {@link PageInfo} object * @param pageInfo The {@link PageInfo} object
* @param nodes The top level nodes already loaded, if any * @param nodes The top level nodes already loaded, if any
* @param selectedItems The currently selected items
*/ */
private retrieveTopNodes(pageInfo: PageInfo, nodes: TreeviewNode[]): void { private retrieveTopNodes(pageInfo: PageInfo, nodes: TreeviewNode[], selectedItems: string[]): void {
this.vocabularyService.searchTopEntries(this.vocabularyName, pageInfo).pipe( this.vocabularyService.searchTopEntries(this.vocabularyName, pageInfo).pipe(
getFirstSucceededRemoteDataPayload() getFirstSucceededRemoteDataPayload()
).subscribe((list: PaginatedList<VocabularyEntryDetail>) => { ).subscribe((list: PaginatedList<VocabularyEntryDetail>) => {
this.vocabularyService.clearSearchTopRequests(); this.vocabularyService.clearSearchTopRequests();
const newNodes: TreeviewNode[] = list.page.map((entry: VocabularyEntryDetail) => this._generateNode(entry)); const newNodes: TreeviewNode[] = list.page.map((entry: VocabularyEntryDetail) => this._generateNode(entry, selectedItems));
nodes.push(...newNodes); nodes.push(...newNodes);
if ((list.pageInfo.currentPage + 1) <= list.pageInfo.totalPages) { if ((list.pageInfo.currentPage + 1) <= list.pageInfo.totalPages) {
@@ -334,15 +344,16 @@ export class VocabularyTreeviewService {
* Build and return the tree node hierarchy by a given vocabulary entry * Build and return the tree node hierarchy by a given vocabulary entry
* *
* @param item The vocabulary entry * @param item The vocabulary entry
* @param selectedItems The currently selected items
* @param children The vocabulary entry * @param children The vocabulary entry
* @param toStore A Boolean representing if the node created is to store or not * @param toStore A Boolean representing if the node created is to store or not
* @return Observable<string[]> * @return Observable<string[]>
*/ */
private getNodeHierarchy(item: VocabularyEntryDetail, children?: TreeviewNode[], toStore = true): Observable<TreeviewNode> { private getNodeHierarchy(item: VocabularyEntryDetail, selectedItems: string[], children?: TreeviewNode[], toStore = true): Observable<TreeviewNode> {
if (isEmpty(item)) { if (isEmpty(item)) {
return observableOf(null); return observableOf(null);
} }
const node = this._generateNode(item, toStore, toStore); const node = this._generateNode(item, selectedItems, toStore, toStore);
if (isNotEmpty(children)) { if (isNotEmpty(children)) {
const newChildren = children const newChildren = children
@@ -357,7 +368,7 @@ export class VocabularyTreeviewService {
if (node.item.hasOtherInformation() && isNotEmpty(node.item.otherInformation.parent)) { if (node.item.hasOtherInformation() && isNotEmpty(node.item.otherInformation.parent)) {
return this.getParentNode(node.item.otherInformation.id).pipe( return this.getParentNode(node.item.otherInformation.id).pipe(
mergeMap((parentItem: VocabularyEntryDetail) => this.getNodeHierarchy(parentItem, [node], toStore)) mergeMap((parentItem: VocabularyEntryDetail) => this.getNodeHierarchy(parentItem, selectedItems, [node], toStore))
); );
} else { } else {
return observableOf(node); return observableOf(node);
@@ -368,15 +379,16 @@ export class VocabularyTreeviewService {
* Build and return the node Hierarchy ids by a given node * Build and return the node Hierarchy ids by a given node
* *
* @param node The given node * @param node The given node
* @param selectedItems The currently selected items
* @param hierarchyIds The ids already present in the Hierarchy's array * @param hierarchyIds The ids already present in the Hierarchy's array
* @return string[] * @return string[]
*/ */
private getNodeHierarchyIds(node: TreeviewNode, hierarchyIds: string[] = []): string[] { private getNodeHierarchyIds(node: TreeviewNode, selectedItems: string[], hierarchyIds: string[] = []): string[] {
if (!hierarchyIds.includes(node.item.otherInformation.id)) { if (!hierarchyIds.includes(node.item.otherInformation.id)) {
hierarchyIds.push(node.item.otherInformation.id); hierarchyIds.push(node.item.otherInformation.id);
} }
if (isNotEmpty(node.children)) { if (isNotEmpty(node.children)) {
return this.getNodeHierarchyIds(node.children[0], hierarchyIds); return this.getNodeHierarchyIds(node.children[0], selectedItems, hierarchyIds);
} else { } else {
return hierarchyIds; return hierarchyIds;
} }

View File

@@ -2,6 +2,7 @@ import { MetadataRepresentationType } from '../../../../core/shared/metadata-rep
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';
import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator'; import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator';
import { VALUE_LIST_BROWSE_DEFINITION } from '../../../../core/shared/value-list-browse-definition.resource-type';
//@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText) //@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText)
// For now, authority controlled fields are rendered the same way as plain text fields // For now, authority controlled fields are rendered the same way as plain text fields
//@metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled) //@metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled)
@@ -21,7 +22,7 @@ export class BrowseLinkMetadataListElementComponent extends MetadataRepresentati
*/ */
getQueryParams() { getQueryParams() {
let queryParams = {startsWith: this.mdRepresentation.getValue()}; let queryParams = {startsWith: this.mdRepresentation.getValue()};
if (this.mdRepresentation.browseDefinition.metadataBrowse) { if (this.mdRepresentation.browseDefinition.getRenderType() === VALUE_LIST_BROWSE_DEFINITION.value) {
return {value: this.mdRepresentation.getValue()}; return {value: this.mdRepresentation.getValue()};
} }
return queryParams; return queryParams;

View File

@@ -2,6 +2,7 @@ import { MetadataRepresentationType } from '../../../../core/shared/metadata-rep
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';
import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator'; import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator';
import { VALUE_LIST_BROWSE_DEFINITION } from '../../../../core/shared/value-list-browse-definition.resource-type';
@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText) @metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText)
// For now, authority controlled fields are rendered the same way as plain text fields // For now, authority controlled fields are rendered the same way as plain text fields
@@ -21,7 +22,7 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio
*/ */
getQueryParams() { getQueryParams() {
let queryParams = {startsWith: this.mdRepresentation.getValue()}; let queryParams = {startsWith: this.mdRepresentation.getValue()};
if (this.mdRepresentation.browseDefinition.metadataBrowse) { if (this.mdRepresentation.browseDefinition.getRenderType() === VALUE_LIST_BROWSE_DEFINITION.value) {
return {value: this.mdRepresentation.getValue()}; return {value: this.mdRepresentation.getValue()};
} }
return queryParams; return queryParams;

View File

@@ -3,7 +3,6 @@ import { renderFacetFor } from '../search-filter-type-decorator';
import { FilterType } from '../../../models/filter-type.model'; import { FilterType } from '../../../models/filter-type.model';
import { facetLoad, SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; import { facetLoad, SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { VocabularyTreeviewComponent } from '../../../../form/vocabulary-treeview/vocabulary-treeview.component';
import { import {
VocabularyEntryDetail VocabularyEntryDetail
} from '../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; } from '../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
@@ -26,6 +25,7 @@ import { Observable, BehaviorSubject } from 'rxjs';
import { PageInfo } from '../../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../../core/shared/page-info.model';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
import { addOperatorToFilterValue } from '../../../search.utils'; import { addOperatorToFilterValue } from '../../../search.utils';
import { VocabularyTreeviewModalComponent } from '../../../../form/vocabulary-treeview-modal/vocabulary-treeview-modal.component';
@Component({ @Component({
selector: 'ds-search-hierarchy-filter', selector: 'ds-search-hierarchy-filter',
@@ -83,7 +83,7 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
* When an entry is selected, add the filter query to the search options. * When an entry is selected, add the filter query to the search options.
*/ */
showVocabularyTree() { showVocabularyTree() {
const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewComponent, { const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewModalComponent, {
size: 'lg', size: 'lg',
windowClass: 'treeview' windowClass: 'treeview'
}); });
@@ -91,7 +91,7 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
name: this.getVocabularyEntry(), name: this.getVocabularyEntry(),
closed: true closed: true
}; };
modalRef.componentInstance.select.subscribe((detail: VocabularyEntryDetail) => { modalRef.result.then((detail: VocabularyEntryDetail) => {
this.selectedValues$ this.selectedValues$
.pipe(take(1)) .pipe(take(1))
.subscribe((selectedValues) => { .subscribe((selectedValues) => {
@@ -106,7 +106,7 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
}, },
); );
}); });
}); }).catch();
} }
/** /**

View File

@@ -67,7 +67,7 @@ export class SearchComponent implements OnInit {
* The configuration to use for the search options * The configuration to use for the search options
* If empty, 'default' is used * If empty, 'default' is used
*/ */
@Input() configuration = 'default'; @Input() configuration;
/** /**
* The actual query for the fixed filter. * The actual query for the fixed filter.

View File

@@ -0,0 +1,13 @@
import { Bitstream } from '../../core/shared/bitstream.model';
import { Observable, of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { NoContent } from '../../core/shared/NoContent.model';
import { RequestEntryState } from '../../core/data/request-entry-state.model';
export class BitstreamDataServiceStub {
removeMultiple(_bitstreams: Bitstream[]): Observable<RemoteData<NoContent>> {
return observableOf(new RemoteData(0, 0, 0, RequestEntryState.Success));
}
}

View File

@@ -5,12 +5,14 @@ import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import { BrowseService } from '../../core/browse/browse.service'; import { BrowseService } from '../../core/browse/browse.service';
import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../remote-data.utils';
import { PageInfo } from '../../core/shared/page-info.model'; import { PageInfo } from '../../core/shared/page-info.model';
import { FlatBrowseDefinition } from '../../core/shared/flat-browse-definition.model';
import { ValueListBrowseDefinition } from '../../core/shared/value-list-browse-definition.model';
// This data is in post-serialized form (metadata -> metadataKeys) // This data is in post-serialized form (metadata -> metadataKeys)
export const mockData: BrowseDefinition[] = [ export const mockData: BrowseDefinition[] = [
Object.assign(new BrowseDefinition, { Object.assign(new FlatBrowseDefinition(), {
'id' : 'dateissued', 'id' : 'dateissued',
'metadataBrowse' : false, 'browseType': 'flatBrowse',
'dataType' : 'date', 'dataType' : 'date',
'sortOptions' : EMPTY, 'sortOptions' : EMPTY,
'order' : 'ASC', 'order' : 'ASC',
@@ -18,9 +20,9 @@ export const mockData: BrowseDefinition[] = [
'metadataKeys' : [ 'dc.date.issued' ], 'metadataKeys' : [ 'dc.date.issued' ],
'_links' : EMPTY '_links' : EMPTY
}), }),
Object.assign(new BrowseDefinition, { Object.assign(new ValueListBrowseDefinition(), {
'id' : 'author', 'id' : 'author',
'metadataBrowse' : true, 'browseType' : 'valueList',
'dataType' : 'text', 'dataType' : 'text',
'sortOptions' : EMPTY, 'sortOptions' : EMPTY,
'order' : 'ASC', 'order' : 'ASC',

View File

@@ -795,6 +795,8 @@
"browse.metadata.subject.breadcrumbs": "Browse by Subject", "browse.metadata.subject.breadcrumbs": "Browse by Subject",
"browse.metadata.srsc.breadcrumbs": "Browse by Subject Category",
"browse.metadata.title.breadcrumbs": "Browse by Title", "browse.metadata.title.breadcrumbs": "Browse by Title",
"pagination.next.button": "Next", "pagination.next.button": "Next",
@@ -851,6 +853,8 @@
"browse.startsWith.input": "Filter", "browse.startsWith.input": "Filter",
"browse.taxonomy.button": "Browse",
"browse.title": "Browsing {{ collection }} by {{ field }}{{ startsWith }} {{ value }}", "browse.title": "Browsing {{ collection }} by {{ field }}{{ startsWith }} {{ value }}",
"browse.title.page": "Browsing {{ collection }} by {{ field }} {{ value }}", "browse.title.page": "Browsing {{ collection }} by {{ field }} {{ value }}",
@@ -2536,6 +2540,8 @@
"item.preview.dc.identifier.doi": "DOI", "item.preview.dc.identifier.doi": "DOI",
"item.preview.dc.publisher": "Publisher:",
"item.preview.person.familyName": "Surname:", "item.preview.person.familyName": "Surname:",
"item.preview.person.givenName": "Name:", "item.preview.person.givenName": "Name:",
@@ -2907,6 +2913,8 @@
"menu.section.browse_global_by_subject": "By Subject", "menu.section.browse_global_by_subject": "By Subject",
"menu.section.browse_global_by_srsc": "By Subject Category",
"menu.section.browse_global_by_title": "By Title", "menu.section.browse_global_by_title": "By Title",
"menu.section.browse_global_communities_and_collections": "Communities & Collections", "menu.section.browse_global_communities_and_collections": "Communities & Collections",

View File

@@ -31,7 +31,6 @@ import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.se
import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service';
import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service'; import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service';
import { BrowserInitService } from './browser-init.service'; import { BrowserInitService } from './browser-init.service';
import { VocabularyTreeviewService } from 'src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service';
export const REQ_KEY = makeStateKey<string>('req'); export const REQ_KEY = makeStateKey<string>('req');
@@ -111,10 +110,6 @@ export function getRequest(transferState: TransferState): any {
{ {
provide: LocationToken, provide: LocationToken,
useFactory: locationProvider, useFactory: locationProvider,
},
{
provide: VocabularyTreeviewService,
useClass: VocabularyTreeviewService,
} }
] ]
}) })

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { BrowseByTaxonomyPageComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component';
@Component({
selector: 'ds-browse-by-taxonomy-page',
// templateUrl: './browse-by-taxonomy-page.component.html',
templateUrl: '../../../../../app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html',
// styleUrls: ['./browse-by-taxonomy-page.component.scss'],
styleUrls: ['../../../../../app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss'],
})
/**
* Component for browsing items by metadata in a hierarchical controlled vocabulary
*/
export class BrowseByTaxonomyPageComponent extends BaseComponent {
}

View File

@@ -101,6 +101,7 @@ import { ObjectListComponent } from './app/shared/object-list/object-list.compon
import { BrowseByMetadataPageComponent } from './app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseByMetadataPageComponent } from './app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component';
import { BrowseByDatePageComponent } from './app/browse-by/browse-by-date-page/browse-by-date-page.component'; import { BrowseByDatePageComponent } from './app/browse-by/browse-by-date-page/browse-by-date-page.component';
import { BrowseByTitlePageComponent } from './app/browse-by/browse-by-title-page/browse-by-title-page.component'; import { BrowseByTitlePageComponent } from './app/browse-by/browse-by-title-page/browse-by-title-page.component';
import { BrowseByTaxonomyPageComponent } from './app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component';
import { import {
ExternalSourceEntryImportModalComponent ExternalSourceEntryImportModalComponent
} from './app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; } from './app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component';
@@ -200,6 +201,7 @@ const DECLARATIONS = [
BrowseByMetadataPageComponent, BrowseByMetadataPageComponent,
BrowseByDatePageComponent, BrowseByDatePageComponent,
BrowseByTitlePageComponent, BrowseByTitlePageComponent,
BrowseByTaxonomyPageComponent,
ExternalSourceEntryImportModalComponent, ExternalSourceEntryImportModalComponent,
SearchFiltersComponent, SearchFiltersComponent,
SearchSidebarComponent, SearchSidebarComponent,