diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 7838bfdd25..208e5415a8 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -388,7 +388,21 @@ } }, "browse": { - "title": "Browsing {{ collection }} by {{ field }} {{ value }}" + "title": "Browsing {{ collection }} by {{ field }} {{ value }}", + "metadata": { + "title": "Title", + "author": "Author", + "subject": "Subject" + }, + "comcol": { + "head": "Browse", + "by": { + "title": "By Title", + "author": "By Author", + "subject": "By Subject" + } + }, + "empty": "No items to show." }, "admin": { "registries": { @@ -519,6 +533,7 @@ "browse_global_by_issue_date": "By Issue Date", "browse_global_by_author": "By Author", "browse_global_by_title": "By Title", + "browse_global_by_subject": "By Subject", "statistics": "Statistics", "browse_community": "This Community", "browse_community_by_issue_date": "By Issue Date", diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts index 3c68157a3e..f335c880ae 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.reducers.ts @@ -32,6 +32,11 @@ const initialState: MetadataRegistryState = { selectedFields: [] }; +/** + * Reducer that handles MetadataRegistryActions to modify metadata schema and/or field states + * @param state The current MetadataRegistryState + * @param action The MetadataRegistryAction to perform on the state + */ export function metadataRegistryReducer(state = initialState, action: MetadataRegistryAction): MetadataRegistryState { switch (action.type) { diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts index 509af6609b..4c09428bd5 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts @@ -95,6 +95,9 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { private translateService: TranslateService) { } + /** + * Initialize the component, setting up the necessary Models for the dynamic form + */ ngOnInit() { combineLatest( this.translateService.get(`${this.messagePrefix}.element`), diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html deleted file mode 100644 index 438c318994..0000000000 --- a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- - -
-
diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts deleted file mode 100644 index 813ee8a32f..0000000000 --- a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts +++ /dev/null @@ -1,107 +0,0 @@ - -import {combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { Component, OnInit } from '@angular/core'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list'; -import { ItemDataService } from '../../core/data/item-data.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { ActivatedRoute } from '@angular/router'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { BrowseService } from '../../core/browse/browse.service'; -import { BrowseEntry } from '../../core/shared/browse-entry.model'; -import { Item } from '../../core/shared/item.model'; - -@Component({ - selector: 'ds-browse-by-author-page', - styleUrls: ['./browse-by-author-page.component.scss'], - templateUrl: './browse-by-author-page.component.html' -}) -/** - * Component for browsing (items) by author (dc.contributor.author) - */ -export class BrowseByAuthorPageComponent implements OnInit { - - authors$: Observable>>; - items$: Observable>>; - paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'browse-by-author-pagination', - currentPage: 1, - pageSize: 20 - }); - sortConfig: SortOptions = new SortOptions('dc.contributor.author', SortDirection.ASC); - subs: Subscription[] = []; - currentUrl: string; - value = ''; - - public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute, private browseService: BrowseService) { - } - - ngOnInit(): void { - this.currentUrl = this.route.snapshot.pathFromRoot - .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') - .join('/'); - this.updatePage({ - pagination: this.paginationConfig, - sort: this.sortConfig - }); - this.subs.push( - observableCombineLatest( - this.route.params, - this.route.queryParams, - (params, queryParams, ) => { - return Object.assign({}, params, queryParams); - }) - .subscribe((params) => { - const page = +params.page || this.paginationConfig.currentPage; - const pageSize = +params.pageSize || this.paginationConfig.pageSize; - const sortDirection = params.sortDirection || this.sortConfig.direction; - const sortField = params.sortField || this.sortConfig.field; - this.value = +params.value || params.value || ''; - const pagination = Object.assign({}, - this.paginationConfig, - { currentPage: page, pageSize: pageSize } - ); - const sort = Object.assign({}, - this.sortConfig, - { direction: sortDirection, field: sortField } - ); - const searchOptions = { - pagination: pagination, - sort: sort - }; - if (isNotEmpty(this.value)) { - this.updatePageWithItems(searchOptions, this.value); - } else { - this.updatePage(searchOptions); - } - })); - } - - /** - * Updates the current page with searchOptions - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - */ - updatePage(searchOptions) { - this.authors$ = this.browseService.getBrowseEntriesFor('author', searchOptions); - this.items$ = undefined; - } - - /** - * Updates the current page with searchOptions and display items linked to author - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - * @param author The author's name for displaying items - */ - updatePageWithItems(searchOptions, author: string) { - this.items$ = this.browseService.getBrowseItemsFor('author', author, searchOptions); - } - - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - } - -} diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html new file mode 100644 index 0000000000..08fb762db0 --- /dev/null +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -0,0 +1,10 @@ +
+ +
diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.scss similarity index 100% rename from src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss rename to src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.scss diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts new file mode 100644 index 0000000000..a53faf6b8b --- /dev/null +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts @@ -0,0 +1,162 @@ +import { BrowseByMetadataPageComponent, browseParamsToOptions } from './browse-by-metadata-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowseService } from '../../core/browse/browse.service'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { SortDirection } from '../../core/cache/models/sort-options.model'; +import { Item } from '../../core/shared/item.model'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { Community } from '../../core/shared/community.model'; + +describe('BrowseByMetadataPageComponent', () => { + let comp: BrowseByMetadataPageComponent; + let fixture: ComponentFixture; + let browseService: BrowseService; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + metadata: [ + { + key: 'dc.title', + value: 'test community' + } + ] + }); + + const mockEntries = [ + { + type: 'author', + authority: null, + value: 'John Doe', + language: 'en', + count: 1 + }, + { + type: 'author', + authority: null, + value: 'James Doe', + language: 'en', + count: 3 + }, + { + type: 'subject', + authority: null, + value: 'Fake subject', + language: 'en', + count: 2 + } + ]; + + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId' + }) + ]; + + const mockBrowseService = { + getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData(mockEntries.filter((entry) => entry.type === options.metadataDefinition)), + getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData(mockItems) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByMetadataPageComponent, EnumKeysPipe], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByMetadataPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + browseService = (comp as any).browseService; + route = (comp as any).route; + route.params = observableOf({}); + comp.ngOnInit(); + fixture.detectChanges(); + }); + + it('should fetch the correct entries depending on the metadata definition', () => { + comp.browseEntries$.subscribe((result) => { + expect(result.payload.page).toEqual(mockEntries.filter((entry) => entry.type === 'author')); + }); + }); + + it('should not fetch any items when no value is provided', () => { + expect(comp.items$).toBeUndefined(); + }); + + describe('when a value is provided', () => { + beforeEach(() => { + const paramsWithValue = { + metadata: 'author', + value: 'John Doe' + }; + + route.params = observableOf(paramsWithValue); + comp.ngOnInit(); + }); + + it('should fetch items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual(mockItems); + }); + }) + }); + + describe('when calling browseParamsToOptions', () => { + let result: BrowseEntrySearchOptions; + + beforeEach(() => { + const paramsWithPaginationAndScope = { + page: 5, + pageSize: 10, + sortDirection: SortDirection.ASC, + sortField: 'fake-field', + scope: 'fake-scope' + }; + + result = browseParamsToOptions(paramsWithPaginationAndScope, Object.assign({}), Object.assign({}), 'author'); + }); + + it('should return BrowseEntrySearchOptions with the correct properties', () => { + expect(result.metadataDefinition).toEqual('author'); + expect(result.pagination.currentPage).toEqual(5); + expect(result.pagination.pageSize).toEqual(10); + expect(result.sort.direction).toEqual(SortDirection.ASC); + expect(result.sort.field).toEqual('fake-field'); + expect(result.scope).toEqual('fake-scope'); + }) + }); +}); + +export function toRemoteData(objects: any[]): Observable>> { + return observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), objects))); +} diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts new file mode 100644 index 0000000000..87ccb20c0b --- /dev/null +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -0,0 +1,182 @@ +import {combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { ActivatedRoute } from '@angular/router'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { BrowseService } from '../../core/browse/browse.service'; +import { BrowseEntry } from '../../core/shared/browse-entry.model'; +import { Item } from '../../core/shared/item.model'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { Community } from '../../core/shared/community.model'; +import { Collection } from '../../core/shared/collection.model'; +import { getSucceededRemoteData } from '../../core/shared/operators'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-browse-by-metadata-page', + styleUrls: ['./browse-by-metadata-page.component.scss'], + templateUrl: './browse-by-metadata-page.component.html' +}) +/** + * Component for browsing (items) by metadata definition + * A metadata definition is a short term used to describe one or multiple metadata fields. + * An example would be 'author' for 'dc.contributor.*' + */ +export class BrowseByMetadataPageComponent implements OnInit { + + /** + * The list of browse-entries to display + */ + browseEntries$: Observable>>; + + /** + * The list of items to display when a value is present + */ + items$: Observable>>; + + /** + * The current Community or Collection we're browsing metadata/items in + */ + parent$: Observable>; + + /** + * The pagination config used to display the values + */ + paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'browse-by-metadata-pagination', + currentPage: 1, + pageSize: 20 + }); + + /** + * The sorting config used to sort the values (defaults to Ascending) + */ + sortConfig: SortOptions = new SortOptions('default', SortDirection.ASC); + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + /** + * The default metadata definition to resort to when none is provided + */ + defaultMetadata = 'author'; + + /** + * The current metadata definition + */ + metadata = this.defaultMetadata; + + /** + * The value we're browing items for + * - When the value is not empty, we're browsing items + * - When the value is empty, we're browsing browse-entries (values for the given metadata definition) + */ + value = ''; + + public constructor(private route: ActivatedRoute, + private browseService: BrowseService, + private dsoService: DSpaceObjectDataService) { + } + + ngOnInit(): void { + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + (params, queryParams, ) => { + return Object.assign({}, params, queryParams); + }) + .subscribe((params) => { + this.metadata = params.metadata || this.defaultMetadata; + this.value = +params.value || params.value || ''; + const searchOptions = browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata); + if (isNotEmpty(this.value)) { + this.updatePageWithItems(searchOptions, this.value); + } else { + this.updatePage(searchOptions); + } + this.updateParent(params.scope); + })); + } + + /** + * Updates the current page with searchOptions + * @param searchOptions Options to narrow down your search: + * { metadata: string + * pagination: PaginationComponentOptions, + * sort: SortOptions, + * scope: string } + */ + updatePage(searchOptions: BrowseEntrySearchOptions) { + this.browseEntries$ = this.browseService.getBrowseEntriesFor(searchOptions); + this.items$ = undefined; + } + + /** + * Updates the current page with searchOptions and display items linked to the given value + * @param searchOptions Options to narrow down your search: + * { metadata: string + * pagination: PaginationComponentOptions, + * sort: SortOptions, + * scope: string } + * @param value The value of the browse-entry to display items for + */ + updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) { + this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions); + } + + /** + * Update the parent Community or Collection using their scope + * @param scope The UUID of the Community or Collection to fetch + */ + updateParent(scope: string) { + if (hasValue(scope)) { + this.parent$ = this.dsoService.findById(scope).pipe( + getSucceededRemoteData() + ); + } + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + +} + +/** + * Function to transform query and url parameters into searchOptions used to fetch browse entries or items + * @param params URL and query parameters + * @param paginationConfig Pagination configuration + * @param sortConfig Sorting configuration + * @param metadata Optional metadata definition to fetch browse entries/items for + */ +export function browseParamsToOptions(params: any, + paginationConfig: PaginationComponentOptions, + sortConfig: SortOptions, + metadata?: string): BrowseEntrySearchOptions { + return new BrowseEntrySearchOptions( + metadata, + Object.assign({}, + paginationConfig, + { + currentPage: +params.page || paginationConfig.currentPage, + pageSize: +params.pageSize || paginationConfig.pageSize + } + ), + Object.assign({}, + sortConfig, + { + direction: params.sortDirection || sortConfig.direction, + field: params.sortField || sortConfig.field + } + ), + params.scope + ); +} diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html index d37727be36..84b0baf1f6 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html @@ -1,9 +1,8 @@
diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts new file mode 100644 index 0000000000..c92e5c64cb --- /dev/null +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts @@ -0,0 +1,85 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { Item } from '../../core/shared/item.model'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec'; +import { BrowseByTitlePageComponent } from './browse-by-title-page.component'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { Community } from '../../core/shared/community.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; + +describe('BrowseByTitlePageComponent', () => { + let comp: BrowseByTitlePageComponent; + let fixture: ComponentFixture; + let itemDataService: ItemDataService; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + metadata: [ + { + key: 'dc.title', + value: 'test community' + } + ] + }); + + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId', + metadata: [ + { + key: 'dc.title', + value: 'Fake Title' + } + ] + }) + ]; + + const mockItemDataService = { + findAll: () => toRemoteData(mockItems) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByTitlePageComponent, EnumKeysPipe], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByTitlePageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + itemDataService = (comp as any).itemDataService; + route = (comp as any).route; + }); + + it('should initialize the list of items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual(mockItems); + }); + }); +}); diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts index e9127dbbab..6ba43c8f10 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -1,16 +1,20 @@ - -import {combineLatest as observableCombineLatest, Observable , Subscription } from 'rxjs'; +import { combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; import { Component, OnInit } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { PaginatedList } from '../../core/data/paginated-list'; import { ItemDataService } from '../../core/data/item-data.service'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { Item } from '../../core/shared/item.model'; -import { ActivatedRoute, PRIMARY_OUTLET, UrlSegmentGroup } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { hasValue } from '../../shared/empty.util'; import { Collection } from '../../core/shared/collection.model'; +import { browseParamsToOptions } from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { Community } from '../../core/shared/community.model'; +import { getSucceededRemoteData } from '../../core/shared/operators'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; @Component({ selector: 'ds-browse-by-title-page', @@ -22,28 +26,44 @@ import { Collection } from '../../core/shared/collection.model'; */ export class BrowseByTitlePageComponent implements OnInit { + /** + * The list of items to display + */ items$: Observable>>; + + /** + * The current Community or Collection we're browsing metadata/items in + */ + parent$: Observable>; + + /** + * The pagination configuration to use for displaying the list of items + */ paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'browse-by-title-pagination', currentPage: 1, pageSize: 20 }); - sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC); - subs: Subscription[] = []; - currentUrl: string; - public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute) { + /** + * The sorting configuration to use for displaying the list of items + * Sorted by title (Ascending by default) + */ + sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC); + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + public constructor(private itemDataService: ItemDataService, + private route: ActivatedRoute, + private dsoService: DSpaceObjectDataService) { } ngOnInit(): void { - this.currentUrl = this.route.snapshot.pathFromRoot - .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') - .join('/'); - this.updatePage({ - pagination: this.paginationConfig, - sort: this.sortConfig - }); + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); this.subs.push( observableCombineLatest( this.route.params, @@ -52,22 +72,8 @@ export class BrowseByTitlePageComponent implements OnInit { return Object.assign({}, params, queryParams); }) .subscribe((params) => { - const page = +params.page || this.paginationConfig.currentPage; - const pageSize = +params.pageSize || this.paginationConfig.pageSize; - const sortDirection = params.sortDirection || this.sortConfig.direction; - const sortField = params.sortField || this.sortConfig.field; - const pagination = Object.assign({}, - this.paginationConfig, - { currentPage: page, pageSize: pageSize } - ); - const sort = Object.assign({}, - this.sortConfig, - { direction: sortDirection, field: sortField } - ); - this.updatePage({ - pagination: pagination, - sort: sort - }); + this.updatePage(browseParamsToOptions(params, this.paginationConfig, this.sortConfig)); + this.updateParent(params.scope) })); } @@ -77,14 +83,27 @@ export class BrowseByTitlePageComponent implements OnInit { * { pagination: PaginationComponentOptions, * sort: SortOptions } */ - updatePage(searchOptions) { + updatePage(searchOptions: BrowseEntrySearchOptions) { this.items$ = this.itemDataService.findAll({ currentPage: searchOptions.pagination.currentPage, elementsPerPage: searchOptions.pagination.pageSize, - sort: searchOptions.sort + sort: searchOptions.sort, + scopeID: searchOptions.scope }); } + /** + * Update the parent Community or Collection using their scope + * @param scope The UUID of the Community or Collection to fetch + */ + updateParent(scope: string) { + if (hasValue(scope)) { + this.parent$ = this.dsoService.findById(scope).pipe( + getSucceededRemoteData() + ); + } + } + ngOnDestroy(): void { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts index 630a7c0db5..38915fffca 100644 --- a/src/app/+browse-by/browse-by-routing.module.ts +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -1,13 +1,13 @@ import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; -import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; +import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; @NgModule({ imports: [ RouterModule.forChild([ { path: 'title', component: BrowseByTitlePageComponent }, - { path: 'author', component: BrowseByAuthorPageComponent } + { path: ':metadata', component: BrowseByMetadataPageComponent } ]) ] }) diff --git a/src/app/+browse-by/browse-by.module.ts b/src/app/+browse-by/browse-by.module.ts index 51843a13d8..38e5001b80 100644 --- a/src/app/+browse-by/browse-by.module.ts +++ b/src/app/+browse-by/browse-by.module.ts @@ -4,8 +4,8 @@ import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-ti import { ItemDataService } from '../core/data/item-data.service'; import { SharedModule } from '../shared/shared.module'; import { BrowseByRoutingModule } from './browse-by-routing.module'; -import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; import { BrowseService } from '../core/browse/browse.service'; +import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; @NgModule({ imports: [ @@ -15,7 +15,7 @@ import { BrowseService } from '../core/browse/browse.service'; ], declarations: [ BrowseByTitlePageComponent, - BrowseByAuthorPageComponent + BrowseByMetadataPageComponent ], providers: [ ItemDataService, diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index a233163070..6e411cb29d 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -7,6 +7,8 @@ + + + + >; logoRD$: Observable>; - private subs: Subscription[] = []; constructor( diff --git a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index ace748c7de..3e6190ae6d 100644 --- a/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/+community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -18,42 +18,38 @@ describe('SubCommunityList Component', () => { const subcommunities = [Object.assign(new Community(), { id: '123456789-1', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'SubCommunity 1' - }] + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 1' } + ] + } }), Object.assign(new Community(), { id: '123456789-2', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'SubCommunity 2' - }] + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 2' } + ] + } }) ]; const emptySubCommunitiesCommunity = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }], + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' } + ] + }, subcommunities: observableOf(new RemoteData(true, true, true, undefined, new PaginatedList(new PageInfo(), []))) }); const mockCommunity = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }], + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' } + ] + }, subcommunities: observableOf(new RemoteData(true, true, true, undefined, new PaginatedList(new PageInfo(), subcommunities))) }) diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html index d59d29ddbf..ce6e01df3d 100644 --- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html @@ -7,10 +7,12 @@ - - {{metadatum.key}} - {{metadatum.value}} - {{metadatum.language}} - + + + {{mdEntry.key}} + {{mdValue.value}} + {{mdValue.language}} + + - \ No newline at end of file + diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts index 942357dc5a..07ad9a347c 100644 --- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts @@ -11,10 +11,14 @@ const mockItem = Object.assign(new Item(), { id: 'fake-id', handle: 'fake/handle', lastModified: '2018', - metadata: [ - {key: 'dc.title', value: 'Mock item title', language: 'en'}, - {key: 'dc.contributor.author', value: 'Mayer, Ed', language: ''} - ] + metadata: { + 'dc.title': [ + { value: 'Mock item title', language: 'en' } + ], + 'dc.contributor.author': [ + { value: 'Mayer, Ed', language: '' } + ] + } }); describe('ModifyItemOverviewComponent', () => { @@ -37,19 +41,19 @@ describe('ModifyItemOverviewComponent', () => { const metadataRows = fixture.debugElement.queryAll(By.css('tr.metadata-row')); expect(metadataRows.length).toEqual(2); - const titleRow = metadataRows[0].queryAll(By.css('td')); - expect(titleRow.length).toEqual(3); - - expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title'); - expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title'); - expect(titleRow[2].nativeElement.innerHTML).toContain('en'); - - const authorRow = metadataRows[1].queryAll(By.css('td')); + const authorRow = metadataRows[0].queryAll(By.css('td')); expect(authorRow.length).toEqual(3); expect(authorRow[0].nativeElement.innerHTML).toContain('dc.contributor.author'); expect(authorRow[1].nativeElement.innerHTML).toContain('Mayer, Ed'); expect(authorRow[2].nativeElement.innerHTML).toEqual(''); + const titleRow = metadataRows[1].queryAll(By.css('td')); + expect(titleRow.length).toEqual(3); + + expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title'); + expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title'); + expect(titleRow[2].nativeElement.innerHTML).toContain('en'); + }); }); diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts index d32a98d5e0..282f8687e1 100644 --- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts @@ -1,6 +1,6 @@ import {Component, Input, OnInit} from '@angular/core'; import {Item} from '../../../core/shared/item.model'; -import {Metadatum} from '../../../core/shared/metadatum.model'; +import {MetadataMap} from '../../../core/shared/metadata.interfaces'; @Component({ selector: 'ds-modify-item-overview', @@ -12,7 +12,7 @@ import {Metadatum} from '../../../core/shared/metadatum.model'; export class ModifyItemOverviewComponent implements OnInit { @Input() item: Item; - metadata: Metadatum[]; + metadata: MetadataMap; ngOnInit(): void { this.metadata = this.item.metadata; diff --git a/src/app/+item-page/field-components/collections/collections.component.spec.ts b/src/app/+item-page/field-components/collections/collections.component.spec.ts index 865ce78a39..53fcded9e3 100644 --- a/src/app/+item-page/field-components/collections/collections.component.spec.ts +++ b/src/app/+item-page/field-components/collections/collections.component.spec.ts @@ -14,12 +14,14 @@ let collectionsComponent: CollectionsComponent; let fixture: ComponentFixture; const mockCollection1: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: observableOf(new RemoteData(false, false, true, null, mockCollection1))}); diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html index cc618bcd50..b5d7c118dd 100644 --- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.html @@ -1,5 +1,5 @@ - - {{ linktext || metadatum.value }} + + {{ linktext || mdValue.value }} diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 212dcddee8..09d855e951 100644 --- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts @@ -1,6 +1,7 @@ import { Component, Input } from '@angular/core'; import { MetadataValuesComponent } from '../metadata-values/metadata-values.component'; +import { MetadataValue } from '../../../core/shared/metadata.interfaces'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link. @@ -18,7 +19,7 @@ export class MetadataUriValuesComponent extends MetadataValuesComponent { @Input() linktext: any; - @Input() values: any; + @Input() mdValues: MetadataValue[]; @Input() separator: string; diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.html b/src/app/+item-page/field-components/metadata-values/metadata-values.component.html index f16655c63c..980c940255 100644 --- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.html +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.html @@ -1,5 +1,5 @@ - - {{metadatum.value}} + + {{mdValue.value}} diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts index 1c94b56d57..708bdb49c7 100644 --- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { Metadatum } from '../../../core/shared/metadatum.model'; +import { MetadataValue } from '../../../core/shared/metadata.interfaces'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. @@ -12,7 +12,7 @@ import { Metadatum } from '../../../core/shared/metadatum.model'; }) export class MetadataValuesComponent { - @Input() values: Metadatum[]; + @Input() mdValues: MetadataValue[]; @Input() separator: string; diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index d5a7febeb9..a68993cd16 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -17,7 +17,7 @@
{{"item.page.filesection.description" | translate}}
-
{{file.findMetadata("dc.description")}}
+
{{file.firstMetadataValue("dc.description")}}
diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html index 1d0c4ab812..7aec57da0c 100644 --- a/src/app/+item-page/full/full-item-page.component.html +++ b/src/app/+item-page/full/full-item-page.component.html @@ -9,11 +9,13 @@
- - - - - + + + + + + +
{{metadatum.key}}{{metadatum.value}}{{metadatum.language}}
{{mdEntry.key}}{{mdValue.value}}{{mdValue.language}}
diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index d09ac268ec..fcb724b564 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -6,7 +6,7 @@ import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; import { ItemPageComponent } from '../simple/item-page.component'; -import { Metadatum } from '../../core/shared/metadatum.model'; +import { MetadataMap } from '../../core/shared/metadata.interfaces'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -34,7 +34,7 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit { itemRD$: Observable>; - metadata$: Observable; + metadata$: Observable; constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) { super(route, items, metadataService); diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html b/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html index 4a27848ec6..d6a569198c 100644 --- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html @@ -1,3 +1,3 @@
- +
diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html index 4c53e2e3e2..aac85d335f 100644 --- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.html @@ -1,3 +1,3 @@

- +

diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html index fde79d6a04..a5561b22e5 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html @@ -1,3 +1,3 @@
- +
diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts index 0683c74aed..3c1a46872c 100644 --- a/src/app/+search-page/normalized-search-result.model.ts +++ b/src/app/+search-page/normalized-search-result.model.ts @@ -1,5 +1,5 @@ import { autoserialize } from 'cerialize'; -import { Metadatum } from '../core/shared/metadatum.model'; +import { MetadataMap } from '../core/shared/metadata.interfaces'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; /** @@ -16,6 +16,6 @@ export class NormalizedSearchResult implements ListableObject { * The metadata that was used to find this item, hithighlighted */ @autoserialize - hitHighlights: Metadatum[]; + hitHighlights: MetadataMap; } diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts index 00b1c62a99..b2e5eafdec 100644 --- a/src/app/+search-page/search-result.model.ts +++ b/src/app/+search-page/search-result.model.ts @@ -1,5 +1,5 @@ import { DSpaceObject } from '../core/shared/dspace-object.model'; -import { Metadatum } from '../core/shared/metadatum.model'; +import { MetadataMap } from '../core/shared/metadata.interfaces'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; /** @@ -14,6 +14,6 @@ export class SearchResult implements ListableObject { /** * The metadata that was used to find this item, hithighlighted */ - hitHighlights: Metadatum[]; + hitHighlights: MetadataMap; } diff --git a/src/app/+search-page/search-results/search-results.component.spec.ts b/src/app/+search-page/search-results/search-results.component.spec.ts index b7ac11553a..8d0566d1df 100644 --- a/src/app/+search-page/search-results/search-results.component.spec.ts +++ b/src/app/+search-page/search-results/search-results.component.spec.ts @@ -111,33 +111,38 @@ export const objects = [ id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', type: ResourceType.Community, - metadata: [ - { - key: 'dc.description', - language: null, - value: '' - }, - { - key: 'dc.description.abstract', - language: null, - value: 'This is a test community to hold content for the OR2017 demostration' - }, - { - key: 'dc.description.tableofcontents', - language: null, - value: '' - }, - { - key: 'dc.rights', - language: null, - value: '' - }, - { - key: 'dc.title', - language: null, - value: 'OR2017 - Demonstration' - } - ] + metadata: { + 'dc.description': [ + { + language: null, + value: '' + } + ], + 'dc.description.abstract': [ + { + language: null, + value: 'This is a test community to hold content for the OR2017 demostration' + } + ], + 'dc.description.tableofcontents': [ + { + language: null, + value: '' + } + ], + 'dc.rights': [ + { + language: null, + value: '' + } + ], + 'dc.title': [ + { + language: null, + value: 'OR2017 - Demonstration' + } + ] + } }), Object.assign(new Community(), { @@ -160,33 +165,38 @@ export const objects = [ id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', type: ResourceType.Community, - metadata: [ - { - key: 'dc.description', - language: null, - value: '

This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).

\r\n

DSpace Communities may contain one or more Sub-Communities or Collections (of Items).

\r\n

This particular Community has its own logo (the DuraSpace logo).

' - }, - { - key: 'dc.description.abstract', - language: null, - value: 'This is a sample top-level community' - }, - { - key: 'dc.description.tableofcontents', - language: null, - value: '

This is the news section for this Sample Community. System or Community Administrators (of this Community) can edit this News field.

' - }, - { - key: 'dc.rights', - language: null, - value: '

If this Community had special copyright text to display, it would be displayed here.

' - }, - { - key: 'dc.title', - language: null, - value: 'Sample Community' - } - ] + metadata: { + 'dc.description': [ + { + language: null, + value: '

This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).

\r\n

DSpace Communities may contain one or more Sub-Communities or Collections (of Items).

\r\n

This particular Community has its own logo (the DuraSpace logo).

' + } + ], + 'dc.description.abstract': [ + { + language: null, + value: 'This is a sample top-level community' + } + ], + 'dc.description.tableofcontents': [ + { + language: null, + value: '

This is the news section for this Sample Community. System or Community Administrators (of this Community) can edit this News field.

' + } + ], + 'dc.rights': [ + { + language: null, + value: '

If this Community had special copyright text to display, it would be displayed here.

' + } + ], + 'dc.title': [ + { + language: null, + value: 'Sample Community' + } + ] + } } ) ]; diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts index a4131db489..ee9f2e571b 100644 --- a/src/app/core/auth/auth-response-parsing.service.spec.ts +++ b/src/app/core/auth/auth-response-parsing.service.spec.ts @@ -60,23 +60,26 @@ describe('AuthResponseParsingService', () => { handle: null, id: '4dc70ab5-cd73-492f-b007-3179d2d9296b', lastActive: '2018-05-14T17:03:31.277+0000', - metadata: [ - { - key: 'eperson.firstname', - language: null, - value: 'User' - }, - { - key: 'eperson.lastname', - language: null, - value: 'Test' - }, - { - key: 'eperson.language', - language: null, - value: 'en' - } - ], + metadata: { + 'eperson.firstname': [ + { + language: null, + value: 'User' + } + ], + 'eperson.lastname': [ + { + language: null, + value: 'Test' + } + ], + 'eperson.language': [ + { + language: null, + value: 'en' + } + ] + }, name: 'User Test', netid: 'myself@testshib.org', requireCertificate: false, diff --git a/src/app/core/browse/browse-entry-search-options.model.ts b/src/app/core/browse/browse-entry-search-options.model.ts new file mode 100644 index 0000000000..a4911a33f1 --- /dev/null +++ b/src/app/core/browse/browse-entry-search-options.model.ts @@ -0,0 +1,17 @@ +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortOptions } from '../cache/models/sort-options.model'; + +/** + * A class that defines the search options to be used for fetching browse entries or items + * - metadataDefinition: The metadata definition to fetch entries or items for + * - pagination: Optional pagination options to use + * - sort: Optional sorting options to use + * - scope: An optional scope to limit the results within a specific collection or community + */ +export class BrowseEntrySearchOptions { + constructor(public metadataDefinition: string, + public pagination?: PaginationComponentOptions, + public sort?: SortOptions, + public scope?: string) { + } +} diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index da75e1a877..d37a87c69e 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -8,6 +8,7 @@ import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseService } from './browse.service'; +import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { RequestEntry } from '../data/request.reducer'; import { of as observableOf } from 'rxjs'; @@ -151,14 +152,14 @@ describe('BrowseService', () => { it('should configure a new BrowseEntriesRequest', () => { const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries); - scheduler.schedule(() => service.getBrowseEntriesFor(browseDefinitions[1].id).subscribe()); + scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(expected); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getBrowseEntriesFor(browseDefinitions[1].id); + service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); @@ -170,14 +171,14 @@ describe('BrowseService', () => { it('should configure a new BrowseItemsRequest', () => { const expected = new BrowseItemsRequest(requestService.generateRequestId(), browseDefinitions[1]._links.items + '?filterValue=' + mockAuthorName); - scheduler.schedule(() => service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName).subscribe()); + scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(expected); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName); + service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(browseDefinitions[1].id)); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); @@ -191,7 +192,7 @@ describe('BrowseService', () => { const definitionID = 'invalidID'; const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) - expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected); + expect(service.getBrowseEntriesFor(new BrowseEntrySearchOptions(definitionID))).toBeObservable(expected); }); }); @@ -201,7 +202,7 @@ describe('BrowseService', () => { const definitionID = 'invalidID'; const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) - expect(service.getBrowseItemsFor(definitionID, mockAuthorName)).toBeObservable(expected); + expect(service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(definitionID))).toBeObservable(expected); }); }); }); @@ -219,44 +220,44 @@ describe('BrowseService', () => { }})); }); - it('should return the URL for the given metadatumKey and linkPath', () => { - const metadatumKey = 'dc.date.issued'; + it('should return the URL for the given metadataKey and linkPath', () => { + const metadataKey = 'dc.date.issued'; const linkPath = 'items'; const expectedURL = browseDefinitions[0]._links[linkPath]; - const result = service.getBrowseURLFor(metadatumKey, linkPath); + const result = service.getBrowseURLFor(metadataKey, linkPath); const expected = cold('c-d-', { c: undefined, d: expectedURL }); expect(result).toBeObservable(expected); }); - it('should work when the definition uses a wildcard in the metadatumKey', () => { - const metadatumKey = 'dc.contributor.author'; // should match dc.contributor.* in the definition + it('should work when the definition uses a wildcard in the metadataKey', () => { + const metadataKey = 'dc.contributor.author'; // should match dc.contributor.* in the definition const linkPath = 'items'; const expectedURL = browseDefinitions[1]._links[linkPath]; - const result = service.getBrowseURLFor(metadatumKey, linkPath); + const result = service.getBrowseURLFor(metadataKey, linkPath); const expected = cold('c-d-', { c: undefined, d: expectedURL }); expect(result).toBeObservable(expected); }); it('should throw an error when the key doesn\'t match', () => { - const metadatumKey = 'dc.title'; // isn't in the definitions + const metadataKey = 'dc.title'; // isn't in the definitions const linkPath = 'items'; - const result = service.getBrowseURLFor(metadatumKey, linkPath); - const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`)); + const result = service.getBrowseURLFor(metadataKey, linkPath); + const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`)); expect(result).toBeObservable(expected); }); it('should throw an error when the link doesn\'t match', () => { - const metadatumKey = 'dc.date.issued'; + const metadataKey = 'dc.date.issued'; const linkPath = 'collections'; // isn't in the definitions - const result = service.getBrowseURLFor(metadatumKey, linkPath); - const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`)); + const result = service.getBrowseURLFor(metadataKey, linkPath); + const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`)); expect(result).toBeObservable(expected); }); @@ -271,10 +272,10 @@ describe('BrowseService', () => { spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('----')); - const metadatumKey = 'dc.date.issued'; + const metadataKey = 'dc.date.issued'; const linkPath = 'items'; - const result = service.getBrowseURLFor(metadatumKey, linkPath); + const result = service.getBrowseURLFor(metadataKey, linkPath); const expected = cold('b---', { b: undefined }); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index b807a77e99..ef4fdaa5ff 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -33,13 +33,17 @@ import { import { URLCombiner } from '../url-combiner/url-combiner'; import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; +/** + * Service that performs all actions that have to do with browse. + */ @Injectable() export class BrowseService { protected linkPath = 'browses'; - private static toSearchKeyArray(metadatumKey: string): string[] { - const keyParts = metadatumKey.split('.'); + private static toSearchKeyArray(metadataKey: string): string[] { + const keyParts = metadataKey.split('.'); const searchFor = []; searchFor.push('*'); for (let i = 0; i < keyParts.length - 1; i++) { @@ -47,7 +51,7 @@ export class BrowseService { const nextPart = [...prevParts, '*'].join('.'); searchFor.push(nextPart); } - searchFor.push(metadatumKey); + searchFor.push(metadataKey); return searchFor; } @@ -80,18 +84,18 @@ export class BrowseService { return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } - getBrowseEntriesFor(definitionID: string, options: { - pagination?: PaginationComponentOptions; - sort?: SortOptions; - } = {}): Observable>> { + getBrowseEntriesFor(options: BrowseEntrySearchOptions): Observable>> { const request$ = this.getBrowseDefinitions().pipe( - getBrowseDefinitionLinks(definitionID), + getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.entries), hasValueOperator(), map((href: string) => { // TODO nearly identical to PaginatedSearchOptions => refactor const args = []; + if (isNotEmpty(options.sort)) { + args.push(`scope=${options.scope}`); + } if (isNotEmpty(options.sort)) { args.push(`sort=${options.sort.field},${options.sort.direction}`); } @@ -133,17 +137,17 @@ export class BrowseService { * sort: SortOptions } * @returns {Observable>>} */ - getBrowseItemsFor(definitionID: string, filterValue: string, options: { - pagination?: PaginationComponentOptions; - sort?: SortOptions; - } = {}): Observable>> { + getBrowseItemsFor(filterValue: string, options: BrowseEntrySearchOptions): Observable>> { const request$ = this.getBrowseDefinitions().pipe( - getBrowseDefinitionLinks(definitionID), + getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.items), hasValueOperator(), map((href: string) => { const args = []; + if (isNotEmpty(options.sort)) { + args.push(`scope=${options.scope}`); + } if (isNotEmpty(options.sort)) { args.push(`sort=${options.sort.field},${options.sort.direction}`); } @@ -179,8 +183,8 @@ export class BrowseService { return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } - getBrowseURLFor(metadatumKey: string, linkPath: string): Observable { - const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey); + getBrowseURLFor(metadataKey: string, linkPath: string): Observable { + const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey); return this.getBrowseDefinitions().pipe( getRemoteDataPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions @@ -191,7 +195,7 @@ export class BrowseService { ), map((def: BrowseDefinition) => { if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { - throw new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`); + throw new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`); } else { return def._links[linkPath]; } diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index e4444ca803..272969050d 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -8,20 +8,24 @@ import { of as observableOf } from 'rxjs'; const pageInfo = new PageInfo(); const array = [ Object.assign(new Item(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Item nr 1' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Item nr 1' + } + ] + } }), Object.assign(new Item(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Item nr 2' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Item nr 2' + } + ] + } }) ]; const paginatedList = new PaginatedList(pageInfo, array); diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts index dc38a8530e..c53607ce79 100644 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -1,7 +1,6 @@ import { autoserialize, autoserializeAs, deserialize, serialize } from 'cerialize'; import { DSpaceObject } from '../../shared/dspace-object.model'; - -import { Metadatum } from '../../shared/metadatum.model'; +import { MetadataMap } from '../../shared/metadata.interfaces'; import { ResourceType } from '../../shared/resource-type'; import { mapsTo } from '../builders/build-decorators'; import { NormalizedObject } from './normalized-object.model'; @@ -46,10 +45,10 @@ export class NormalizedDSpaceObject extends NormalizedOb type: ResourceType; /** - * An array containing all metadata of this DSpaceObject + * All metadata of this DSpaceObject */ - @autoserializeAs(Metadatum) - metadata: Metadatum[]; + @autoserialize + metadata: MetadataMap; /** * An array of DSpaceObjects that are direct parents of this DSpaceObject diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index d2531b03ab..bb150b3bcb 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -33,6 +33,9 @@ export class DSOSuccessResponse extends RestResponse { } } +/** + * A successful response containing a list of MetadataSchemas wrapped in a RegistryMetadataschemasResponse + */ export class RegistryMetadataschemasSuccessResponse extends RestResponse { constructor( public metadataschemasResponse: RegistryMetadataschemasResponse, @@ -43,6 +46,9 @@ export class RegistryMetadataschemasSuccessResponse extends RestResponse { } } +/** + * A successful response containing a list of MetadataFields wrapped in a RegistryMetadatafieldsResponse + */ export class RegistryMetadatafieldsSuccessResponse extends RestResponse { constructor( public metadatafieldsResponse: RegistryMetadatafieldsResponse, @@ -53,6 +59,9 @@ export class RegistryMetadatafieldsSuccessResponse extends RestResponse { } } +/** + * A successful response containing a list of BitstreamFormats wrapped in a RegistryBitstreamformatsResponse + */ export class RegistryBitstreamformatsSuccessResponse extends RestResponse { constructor( public bitstreamformatsResponse: RegistryBitstreamformatsResponse, @@ -63,6 +72,9 @@ export class RegistryBitstreamformatsSuccessResponse extends RestResponse { } } +/** + * A successful response containing exactly one MetadataSchema + */ export class MetadataschemaSuccessResponse extends RestResponse { constructor( public metadataschema: MetadataSchema, @@ -72,6 +84,9 @@ export class MetadataschemaSuccessResponse extends RestResponse { } } +/** + * A successful response containing exactly one MetadataField + */ export class MetadatafieldSuccessResponse extends RestResponse { constructor( public metadatafield: MetadataField, diff --git a/src/app/core/data/browse-entries-response-parsing.service.spec.ts b/src/app/core/data/browse-entries-response-parsing.service.spec.ts index a61da7aa95..ee706d202c 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.spec.ts @@ -105,21 +105,6 @@ describe('BrowseEntriesResponseParsingService', () => { } as DSpaceRESTV2Response; const invalidResponseNotAList = { - payload: { - authority: null, - value: 'Arulmozhiyal, Ramaswamy', - valueLang: null, - count: 1, - type: 'browseEntry', - _links: { - self: { - href: 'https://rest.api/discover/browses/author/entries' - }, - items: { - href: 'https://rest.api/discover/browses/author/items?filterValue=Arulmozhiyal, Ramaswamy' - } - }, - }, statusCode: '200' } as DSpaceRESTV2Response; diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts index 39600b637d..d61df1f611 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -30,10 +30,12 @@ export class BrowseEntriesResponseParsingService extends BaseResponseParsingServ } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) - && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceRESTv2Serializer(BrowseEntry); - const browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + if (isNotEmpty(data.payload)) { + let browseEntries = []; + if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceRESTv2Serializer(BrowseEntry); + browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + } return new GenericSuccessResponse(browseEntries, data.statusCode, this.processPageInfo(data.payload)); } else { return new ErrorResponse( diff --git a/src/app/core/data/browse-items-response-parsing-service.spec.ts b/src/app/core/data/browse-items-response-parsing-service.spec.ts index 99ea474dc6..f512a9af26 100644 --- a/src/app/core/data/browse-items-response-parsing-service.spec.ts +++ b/src/app/core/data/browse-items-response-parsing-service.spec.ts @@ -24,13 +24,14 @@ describe('BrowseItemsResponseParsingService', () => { uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', handle: '10986/17472', - metadata: [ - { - key: 'dc.creator', - value: 'World Bank', - language: null - } - ], + metadata: { + 'dc.creator': [ + { + value: 'World Bank', + language: null + } + ] + }, inArchive: true, discoverable: true, withdrawn: false, @@ -56,13 +57,14 @@ describe('BrowseItemsResponseParsingService', () => { uuid: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b', name: 'Development of Local Supply Chain : The Missing Link for Concentrated Solar Power Projects in India', handle: '10986/17475', - metadata: [ - { - key: 'dc.creator', - value: 'World Bank', - language: null - } - ], + metadata: { + 'dc.creator': [ + { + value: 'World Bank', + language: null + } + ] + }, inArchive: true, discoverable: true, withdrawn: false, @@ -115,13 +117,14 @@ describe('BrowseItemsResponseParsingService', () => { uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', handle: '10986/17472', - metadata: [ - { - key: 'dc.creator', - value: 'World Bank', - language: null - } - ], + metadata: { + 'dc.creator': [ + { + value: 'World Bank', + language: null + } + ] + }, inArchive: true, discoverable: true, withdrawn: false, diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index fddd7b263c..3cb0b1e8ff 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -29,7 +29,13 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const processRequestDTO = this.process, ResourceType>(data.payload, request.uuid); + let processRequestDTO; + // Prevent empty pages returning an error, initialize empty array instead. + if (hasValue(data.payload) && hasValue(data.payload.page) && data.payload.page.totalElements === 0) { + processRequestDTO = { page: [] }; + } else { + processRequestDTO = this.process, ResourceType>(data.payload, request.uuid); + } let objectList = processRequestDTO; if (hasNoValue(processRequestDTO)) { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 3fcdf57498..a2f6a1cc14 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -15,9 +15,9 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions, PatchRequest, RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { configureRequest, getRequestFromRequestHref } from '../shared/operators'; import { RequestEntry } from './request.reducer'; diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index ab5b859530..05879d6fbb 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -16,6 +16,9 @@ import { HttpClient } from '@angular/common/http'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +/** + * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint + */ @Injectable() export class MetadataSchemaDataService extends DataService { protected linkPath = 'metadataschemas'; diff --git a/src/app/core/data/metadatafield-parsing.service.ts b/src/app/core/data/metadatafield-parsing.service.ts index 2ad95e920b..86a3c8a925 100644 --- a/src/app/core/data/metadatafield-parsing.service.ts +++ b/src/app/core/data/metadatafield-parsing.service.ts @@ -6,6 +6,9 @@ import { Injectable } from '@angular/core'; import { MetadatafieldSuccessResponse, MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models'; import { MetadataField } from '../metadata/metadatafield.model'; +/** + * A service responsible for parsing DSpaceRESTV2Response data related to a single MetadataField to a valid RestResponse + */ @Injectable() export class MetadatafieldParsingService implements ResponseParsingService { diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 1126899279..18820dbd43 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -220,6 +220,9 @@ export class IntegrationRequest extends GetRequest { } } +/** + * Request to create a MetadataSchema + */ export class CreateMetadataSchemaRequest extends PostRequest { constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { super(uuid, href, body, options); @@ -230,6 +233,9 @@ export class CreateMetadataSchemaRequest extends PostRequest { } } +/** + * Request to update a MetadataSchema + */ export class UpdateMetadataSchemaRequest extends PutRequest { constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { super(uuid, href, body, options); @@ -240,6 +246,9 @@ export class UpdateMetadataSchemaRequest extends PutRequest { } } +/** + * Request to create a MetadataField + */ export class CreateMetadataFieldRequest extends PostRequest { constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { super(uuid, href, body, options); @@ -250,6 +259,9 @@ export class CreateMetadataFieldRequest extends PostRequest { } } +/** + * Request to update a MetadataField + */ export class UpdateMetadataFieldRequest extends PutRequest { constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { super(uuid, href, body, options); diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts index 7158b1818b..e324e4d5a2 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -110,6 +110,11 @@ function resetResponseTimestamps(state: RequestState, action: ResetResponseTimes return newState; } +/** + * Remove a request from the RequestState + * @param state The current RequestState + * @param action The RequestRemoveAction to perform + */ function removeRequest(state: RequestState, action: RequestRemoveAction): RequestState { const newState = Object.create(null); for (const value in state) { diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 56a90d185f..93a7a10506 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -55,10 +55,23 @@ export class RequestService { return pathSelector(coreSelector, 'index', IndexName.UUID_MAPPING, uuid); } + /** + * Create a selector that fetches a list of request UUIDs from a given index substate of which the request href + * contains a given substring + * @param selector MemoizedSelector to start from + * @param name The name of the index substate we're fetching request UUIDs from + * @param href Substring that the request's href should contain + */ private uuidsFromHrefSubstringSelector(selector: MemoizedSelector, name: string, href: string): MemoizedSelector { return createSelector(selector, (state: IndexState) => this.getUuidsFromHrefSubstring(state, name, href)); } + /** + * Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring + * @param state The IndexState + * @param name The name of the index substate we're fetching request UUIDs from + * @param href Substring that the request's href should contain + */ private getUuidsFromHrefSubstring(state: IndexState, name: string, href: string): string[] { let result = []; if (isNotEmpty(state)) { diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 7ee2b60f89..46b2572c4e 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -7,7 +7,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { hasValue } from '../../shared/empty.util'; import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; -import { Metadatum } from '../shared/metadatum.model'; +import { MetadataMap, MetadataValue } from '../shared/metadata.interfaces'; @Injectable() export class SearchResponseParsingService implements ResponseParsingService { @@ -16,17 +16,17 @@ export class SearchResponseParsingService implements ResponseParsingService { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const payload = data.payload._embedded.searchResult; - const hitHighlights = payload._embedded.objects + const hitHighlights: MetadataMap[] = payload._embedded.objects .map((object) => object.hitHighlights) .map((hhObject) => { + const mdMap: MetadataMap = {}; if (hhObject) { - return Object.keys(hhObject).map((key) => Object.assign(new Metadatum(), { - key: key, - value: hhObject[key].join('...') - })) - } else { - return []; + for (const key of Object.keys(hhObject)) { + const value: MetadataValue = { value: hhObject[key].join('...'), language: null }; + mdMap[key] = [ value ]; + } } + return mdMap; }); const dsoSelfLinks = payload._embedded.objects diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 20d6b1dfb3..6bf5eb0818 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -91,9 +91,9 @@ export class DSpaceRESTv2Service { const form: FormData = new FormData(); form.append('name', dso.name); if (dso.metadata) { - for (const i of Object.keys(dso.metadata)) { - if (isNotEmpty(dso.metadata[i].value)) { - form.append(dso.metadata[i].key, dso.metadata[i].value); + for (const key of Object.keys(dso.metadata)) { + for (const value of dso.allMetadataValues(key)) { + form.append(key, value); } } } diff --git a/src/app/core/index/index.reducer.ts b/src/app/core/index/index.reducer.ts index 64b41e2111..3597c786d8 100644 --- a/src/app/core/index/index.reducer.ts +++ b/src/app/core/index/index.reducer.ts @@ -65,6 +65,11 @@ function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValu }); } +/** + * Remove values from the IndexState's substate that contain a given substring + * @param state The IndexState to remove values from + * @param action The RemoveFromIndexByValueAction containing the necessary information to remove the values + */ function removeFromIndexBySubstring(state: IndexState, action: RemoveFromIndexByValueAction): IndexState { const subState = state[action.payload.name]; const newSubState = Object.create(null); diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 25579a0690..15b1bb52df 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -37,6 +37,7 @@ import { HttpClient } from '@angular/common/http'; import { EmptyError } from 'rxjs/internal-compatibility'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { MetadataValue } from '../shared/metadata.interfaces'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -152,7 +153,7 @@ describe('MetadataService', () => { expect(title.getTitle()).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane'); - expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26T19:58:25Z'); + expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26'); expect(tagStore.get('citation_issn')[0].content).toEqual('123456789'); expect(tagStore.get('citation_language')[0].content).toEqual('en'); expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3'); @@ -216,23 +217,18 @@ describe('MetadataService', () => { const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; - for (const metadatum of typedMockItem.metadata) { - if (metadatum.key === 'dc.type') { - metadatum.value = type; - break; - } - } + typedMockItem.metadata['dc.type'] = [ { value: type } ] as MetadataValue[]; return typedMockItem; } const mockPublisher = (mockItem: Item): Item => { const publishedMockItem = Object.assign(new Item(), mockItem) as Item; - publishedMockItem.metadata.push({ - uuid: 'b3826cf5-5f07-44cf-88d8-2da968354d18', - key: 'dc.publisher', - language: 'en_US', - value: 'Mock Publisher' - }); + publishedMockItem.metadata['dc.publisher'] = [ + { + language: 'en_US', + value: 'Mock Publisher' + } + ] as MetadataValue[]; return publishedMockItem; } diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 9dbfae3f90..736bf11923 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -294,6 +294,10 @@ export class MetadataService { } } + private hasType(value: string): boolean { + return this.currentObject.value.hasMetadata('dc.type', { value: value, ignoreCase: true }); + } + /** * Returns true if this._item is a dissertation * @@ -301,14 +305,7 @@ export class MetadataService { * true if this._item has a dc.type equal to 'Thesis' */ private isDissertation(): boolean { - let isDissertation = false; - for (const metadatum of this.currentObject.value.metadata) { - if (metadatum.key === 'dc.type') { - isDissertation = metadatum.value.toLowerCase() === 'thesis'; - break; - } - } - return isDissertation; + return this.hasType('thesis'); } /** @@ -318,40 +315,15 @@ export class MetadataService { * true if this._item has a dc.type equal to 'Technical Report' */ private isTechReport(): boolean { - let isTechReport = false; - for (const metadatum of this.currentObject.value.metadata) { - if (metadatum.key === 'dc.type') { - isTechReport = metadatum.value.toLowerCase() === 'technical report'; - break; - } - } - return isTechReport; + return this.hasType('technical report'); } private getMetaTagValue(key: string): string { - let value: string; - for (const metadatum of this.currentObject.value.metadata) { - if (metadatum.key === key) { - value = metadatum.value; - } - } - return value; + return this.currentObject.value.firstMetadataValue(key); } private getFirstMetaTagValue(keys: string[]): string { - let value: string; - for (const metadatum of this.currentObject.value.metadata) { - for (const key of keys) { - if (key === metadatum.key) { - value = metadatum.value; - break; - } - } - if (value !== undefined) { - break; - } - } - return value; + return this.currentObject.value.firstMetadataValue(keys); } private getMetaTagValuesAndCombine(key: string): string { @@ -359,15 +331,7 @@ export class MetadataService { } private getMetaTagValues(keys: string[]): string[] { - const values: string[] = []; - for (const metadatum of this.currentObject.value.metadata) { - for (const key of keys) { - if (key === metadatum.key) { - values.push(metadatum.value); - } - } - } - return values; + return this.currentObject.value.allMetadataValues(keys); } private addMetaTag(property: string, content: string): void { diff --git a/src/app/core/metadata/normalized-metadata-schema.model.ts b/src/app/core/metadata/normalized-metadata-schema.model.ts index 0a19c874e2..c121938940 100644 --- a/src/app/core/metadata/normalized-metadata-schema.model.ts +++ b/src/app/core/metadata/normalized-metadata-schema.model.ts @@ -1,21 +1,35 @@ import { autoserialize } from 'cerialize'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { mapsTo } from '../cache/builders/build-decorators'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { MetadataSchema } from './metadataschema.model'; +/** + * Normalized class for a DSpace MetadataSchema + */ @mapsTo(MetadataSchema) export class NormalizedMetadataSchema extends NormalizedObject implements ListableObject { + /** + * The unique identifier for this schema + */ @autoserialize id: number; + /** + * The REST link to itself + */ @autoserialize self: string; + /** + * A unique prefix that defines this schema + */ @autoserialize prefix: string; + /** + * The namespace for this schema + */ @autoserialize namespace: string; } diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 539cb6a596..dbc60eeec0 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -7,10 +7,11 @@ import { MetadataSchema } from '../metadata/metadataschema.model'; import { MetadataField } from '../metadata/metadatafield.model'; import { BitstreamFormat } from './mock-bitstream-format.model'; import { + CreateMetadataFieldRequest, CreateMetadataSchemaRequest, DeleteRequest, GetRequest, - RestRequest, + RestRequest, UpdateMetadataFieldRequest, UpdateMetadataSchemaRequest } from '../data/request.models'; import { GenericConstructor } from '../shared/generic-constructor'; @@ -375,6 +376,14 @@ export class RegistryService { return this.store.pipe(select(selectedMetadataFieldsSelector)); } + /** + * Create or Update a MetadataSchema + * If the MetadataSchema contains an id, it is assumed the schema already exists and is updated instead + * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint): + * - On creation, a CreateMetadataSchemaRequest is used + * - On update, a UpdateMetadataSchemaRequest is used + * @param schema The MetadataSchema to create or update + */ public createOrUpdateMetadataSchema(schema: MetadataSchema): Observable { const isUpdate = hasValue(schema.id); const requestId = this.requestService.generateRequestId(); @@ -438,6 +447,14 @@ export class RegistryService { ) } + /** + * Create or Update a MetadataField + * If the MetadataField contains an id, it is assumed the field already exists and is updated instead + * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint): + * - On creation, a CreateMetadataFieldRequest is used + * - On update, a UpdateMetadataFieldRequest is used + * @param field The MetadataField to create or update + */ public createOrUpdateMetadataField(field: MetadataField): Observable { const isUpdate = hasValue(field.id); const requestId = this.requestService.generateRequestId(); @@ -455,9 +472,9 @@ export class RegistryService { let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/json'); options.headers = headers; - return new UpdateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(field), options); + return new UpdateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field), options); } else { - return new CreateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(field)); + return new CreateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field)); } }) ); diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index 8fdc14bd6e..c630c9dd57 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -16,7 +16,7 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.description */ get introductoryText(): string { - return this.findMetadata('dc.description'); + return this.firstMetadataValue('dc.description'); } /** @@ -24,7 +24,7 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.description.abstract */ get shortDescription(): string { - return this.findMetadata('dc.description.abstract'); + return this.firstMetadataValue('dc.description.abstract'); } /** @@ -32,7 +32,7 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.rights */ get copyrightText(): string { - return this.findMetadata('dc.rights'); + return this.firstMetadataValue('dc.rights'); } /** @@ -40,7 +40,7 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.rights.license */ get license(): string { - return this.findMetadata('dc.rights.license'); + return this.firstMetadataValue('dc.rights.license'); } /** @@ -48,7 +48,7 @@ export class Collection extends DSpaceObject { * Corresponds to the metadata field dc.description.tableofcontents */ get sidebarText(): string { - return this.findMetadata('dc.description.tableofcontents'); + return this.firstMetadataValue('dc.description.tableofcontents'); } /** diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index c5f6e0ab87..c4e703fd7f 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -17,7 +17,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.description */ get introductoryText(): string { - return this.findMetadata('dc.description'); + return this.firstMetadataValue('dc.description'); } /** @@ -25,7 +25,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.description.abstract */ get shortDescription(): string { - return this.findMetadata('dc.description.abstract'); + return this.firstMetadataValue('dc.description.abstract'); } /** @@ -33,7 +33,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.rights */ get copyrightText(): string { - return this.findMetadata('dc.rights'); + return this.firstMetadataValue('dc.rights'); } /** @@ -41,7 +41,7 @@ export class Community extends DSpaceObject { * Corresponds to the metadata field dc.description.tableofcontents */ get sidebarText(): string { - return this.findMetadata('dc.description.tableofcontents'); + return this.firstMetadataValue('dc.description.tableofcontents'); } /** diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 3e08da151c..edd82eca85 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,5 +1,5 @@ -import { Metadatum } from './metadatum.model' -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces'; +import { Metadata } from './metadata.model'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; @@ -35,14 +35,14 @@ export class DSpaceObject implements CacheableObject, ListableObject { * The name for this DSpaceObject */ get name(): string { - return this.findMetadata('dc.title'); + return this.firstMetadataValue('dc.title'); } /** - * An array containing all metadata of this DSpaceObject + * All metadata of this DSpaceObject */ @autoserialize - metadata: Metadatum[] = []; + metadata: MetadataMap; /** * An array of DSpaceObjects that are direct parents of this DSpaceObject @@ -55,41 +55,58 @@ export class DSpaceObject implements CacheableObject, ListableObject { owner: Observable>; /** - * Find a metadata field by key and language + * Gets all matching metadata in this DSpaceObject. * - * This method returns the value of the first element - * in the metadata array that matches the provided - * key and language - * - * @param key - * @param language - * @return string + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue[]} the matching values or an empty array. */ - findMetadata(key: string, language?: string): string { - const metadatum = this.metadata.find((m: Metadatum) => { - return m.key === key && (isEmpty(language) || m.language === language) - }); - if (isNotEmpty(metadatum)) { - return metadatum.value; - } else { - return undefined; - } + allMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue[] { + return Metadata.all(this.metadata, keyOrKeys, valueFilter); } /** - * Find metadata by an array of keys + * Like [[allMetadata]], but only returns string values. * - * This method returns the values of the element - * in the metadata array that match the provided - * key(s) - * - * @param key(s) - * @return Array + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string[]} the matching string values or an empty array. */ - filterMetadata(keys: string[]): Metadatum[] { - return this.metadata.filter((metadatum: Metadatum) => { - return keys.some((key) => key === metadatum.key); - }); + allMetadataValues(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string[] { + return Metadata.allValues(this.metadata, keyOrKeys, valueFilter); + } + + /** + * Gets the first matching MetadataValue object in this DSpaceObject, or `undefined`. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue} the first matching value, or `undefined`. + */ + firstMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue { + return Metadata.first(this.metadata, keyOrKeys, valueFilter); + } + + /** + * Like [[firstMetadata]], but only returns a string value, or `undefined`. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string} the first matching string value, or `undefined`. + */ + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { + return Metadata.firstValue(this.metadata, keyOrKeys, valueFilter); + } + + /** + * Checks for a matching metadata value in this DSpaceObject. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {boolean} whether a match is found. + */ + hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean { + return Metadata.has(this.metadata, keyOrKeys, valueFilter); } } diff --git a/src/app/core/shared/metadata.interfaces.ts b/src/app/core/shared/metadata.interfaces.ts new file mode 100644 index 0000000000..3590117ce8 --- /dev/null +++ b/src/app/core/shared/metadata.interfaces.ts @@ -0,0 +1,30 @@ +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +export interface MetadataMap { + [ key: string ]: MetadataValue[]; +} + +/** A single metadata value and its properties. */ +export interface MetadataValue { + + /** The language. */ + language: string; + + /** The string value. */ + value: string; +} + +/** Constraints for matching metadata values. */ +export interface MetadataValueFilter { + + /** The language constraint. */ + language?: string; + + /** The value constraint. */ + value?: string; + + /** Whether the value constraint should match without regard to case. */ + ignoreCase?: boolean; + + /** Whether the value constraint should match as a substring. */ + substring?: boolean; +} diff --git a/src/app/core/shared/metadata.model.spec.ts b/src/app/core/shared/metadata.model.spec.ts new file mode 100644 index 0000000000..dfeff8d600 --- /dev/null +++ b/src/app/core/shared/metadata.model.spec.ts @@ -0,0 +1,175 @@ +import { isUndefined } from '../../shared/empty.util'; +import { MetadataValue, MetadataValueFilter } from './metadata.interfaces'; +import { Metadata } from './metadata.model'; + +const mdValue = (value: string, language?: string): MetadataValue => { + return { value: value, language: isUndefined(language) ? null : language }; +} + +const dcDescription = mdValue('Some description'); +const dcAbstract = mdValue('Some abstract'); +const dcTitle0 = mdValue('Title 0'); +const dcTitle1 = mdValue('Title 1'); +const dcTitle2 = mdValue('Title 2', 'en_US'); +const bar = mdValue('Bar'); + +const singleMap = { 'dc.title': [ dcTitle0 ] }; + +const multiMap = { + 'dc.description': [ dcDescription ], + 'dc.description.abstract': [ dcAbstract ], + 'dc.title': [ dcTitle1, dcTitle2 ], + 'foo': [ bar ] +}; + +const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => { + const keys = keyOrKeys instanceof Array ? keyOrKeys : [ keyOrKeys ]; + describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys))) + + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { + const result = fn(mapOrMaps, keys, filter); + let shouldReturn; + if (resultKind === 'boolean') { + shouldReturn = expected; + } else if (isUndefined(expected)) { + shouldReturn = 'undefined'; + } else if (expected instanceof Array) { + shouldReturn = 'an array with ' + expected.length + ' ' + (expected.length > 1 ? 'ordered ' : '') + + resultKind + (expected.length !== 1 ? 's' : ''); + } else { + shouldReturn = 'a ' + resultKind; + } + it('should return ' + shouldReturn, () => { + expect(result).toEqual(expected); + }); + }) +}; + +describe('Metadata', () => { + + describe('all method', () => { + + const testAll = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, expected, filter); + + describe('with emptyMap', () => { + testAll({}, 'foo', []); + testAll({}, '*', []); + }); + describe('with singleMap', () => { + testAll(singleMap, 'foo', []); + testAll(singleMap, '*', [ dcTitle0 ]); + testAll(singleMap, '*', [], { value: 'baz' }); + testAll(singleMap, 'dc.title', [ dcTitle0 ]); + testAll(singleMap, 'dc.*', [ dcTitle0 ]); + }); + describe('with multiMap', () => { + testAll(multiMap, 'foo', [ bar ]); + testAll(multiMap, '*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2, bar ]); + testAll(multiMap, 'dc.title', [ dcTitle1, dcTitle2 ]); + testAll(multiMap, 'dc.*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2 ]); + testAll(multiMap, [ 'dc.title', 'dc.*' ], [ dcTitle1, dcTitle2, dcDescription, dcAbstract ]); + }); + describe('with [ singleMap, multiMap ]', () => { + testAll([ singleMap, multiMap ], 'foo', [ bar ]); + testAll([ singleMap, multiMap ], '*', [ dcTitle0 ]); + testAll([ singleMap, multiMap ], 'dc.title', [ dcTitle0 ]); + testAll([ singleMap, multiMap ], 'dc.*', [ dcTitle0 ]); + }); + describe('with [ multiMap, singleMap ]', () => { + testAll([ multiMap, singleMap ], 'foo', [ bar ]); + testAll([ multiMap, singleMap ], '*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2, bar ]); + testAll([ multiMap, singleMap ], 'dc.title', [ dcTitle1, dcTitle2 ]); + testAll([ multiMap, singleMap ], 'dc.*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2 ]); + testAll([ multiMap, singleMap ], [ 'dc.title', 'dc.*' ], [ dcTitle1, dcTitle2, dcDescription, dcAbstract ]); + }); + }); + + describe('allValues method', () => { + + const testAllValues = (mapOrMaps, keyOrKeys, expected) => + testMethod(Metadata.allValues, 'string', mapOrMaps, keyOrKeys, expected); + + describe('with emptyMap', () => { + testAllValues({}, '*', []); + }); + describe('with singleMap', () => { + testAllValues([ singleMap, multiMap ], '*', [ dcTitle0.value ]); + }); + describe('with [ multiMap, singleMap ]', () => { + testAllValues([ multiMap, singleMap ], '*', [ dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value ]); + }); + }); + + describe('first method', () => { + + const testFirst = (mapOrMaps, keyOrKeys, expected) => + testMethod(Metadata.first, 'value', mapOrMaps, keyOrKeys, expected); + + describe('with emptyMap', () => { + testFirst({}, '*', undefined); + }); + describe('with singleMap', () => { + testFirst(singleMap, '*', dcTitle0); + }); + describe('with [ multiMap, singleMap ]', () => { + testFirst([ multiMap, singleMap ], '*', dcDescription); + }); + }); + + describe('firstValue method', () => { + + const testFirstValue = (mapOrMaps, keyOrKeys, expected) => + testMethod(Metadata.firstValue, 'value', mapOrMaps, keyOrKeys, expected); + + describe('with emptyMap', () => { + testFirstValue({}, '*', undefined); + }); + describe('with singleMap', () => { + testFirstValue(singleMap, '*', dcTitle0.value); + }); + describe('with [ multiMap, singleMap ]', () => { + testFirstValue([ multiMap, singleMap ], '*', dcDescription.value); + }); + }); + + describe('has method', () => { + + const testHas = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.has, 'boolean', mapOrMaps, keyOrKeys, expected, filter); + + describe('with emptyMap', () => { + testHas({}, '*', false); + }); + describe('with singleMap', () => { + testHas(singleMap, '*', true); + testHas(singleMap, '*', false, { value: 'baz' }); + }); + describe('with [ multiMap, singleMap ]', () => { + testHas([ multiMap, singleMap ], '*', true); + }); + }); + + describe('valueMatches method', () => { + + const testValueMatches = (value: MetadataValue, expected: boolean, filter?: MetadataValueFilter) => { + describe('with value ' + JSON.stringify(value) + ' and filter ' + + (isUndefined(filter) ? 'undefined' : JSON.stringify(filter)), () => { + const result = Metadata.valueMatches(value, filter); + it('should return ' + expected, () => { + expect(result).toEqual(expected); + }); + }); + }; + + testValueMatches(mdValue('a'), true); + testValueMatches(mdValue('a'), true, { value: 'a' }); + testValueMatches(mdValue('a'), false, { value: 'A' }); + testValueMatches(mdValue('a'), true, { value: 'A', ignoreCase: true }); + testValueMatches(mdValue('ab'), false, { value: 'b' }); + testValueMatches(mdValue('ab'), true, { value: 'b', substring: true }); + testValueMatches(mdValue('a'), true, { language: null }); + testValueMatches(mdValue('a'), false, { language: 'en_US' }); + testValueMatches(mdValue('a', 'en_US'), true, { language: 'en_US' }); + }); + +}); diff --git a/src/app/core/shared/metadata.model.ts b/src/app/core/shared/metadata.model.ts new file mode 100644 index 0000000000..2b29659252 --- /dev/null +++ b/src/app/core/shared/metadata.model.ts @@ -0,0 +1,163 @@ +import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces'; + +/** + * Utility class for working with DSpace object metadata. + * + * When specifying metadata keys, wildcards are supported, so `'*'` will match all keys, `'dc.date.*'` will + * match all qualified dc dates, and so on. Exact keys will be evaluated (and matches returned) in the order + * they are given. + * + * When multiple keys in a map match a given wildcard, they are evaluated in the order they are stored in + * the map (alphanumeric if obtained from the REST api). If duplicate or overlapping keys are specified, the + * first one takes precedence. For example, specifying `['dc.date', 'dc.*', '*']` will cause any `dc.date` + * values to be evaluated (and returned, if matched) first, followed by any other `dc` metadata values, + * followed by any other (non-dc) metadata values. + */ +export class Metadata { + + /** + * Gets all matching metadata in the map(s). + * + * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). When multiple maps are given, they will be + * checked in order, and only values from the first with at least one match will be returned. + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue[]} the matching values or an empty array. + */ + public static all(mapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): MetadataValue[] { + const mdMaps: MetadataMap[] = mapOrMaps instanceof Array ? mapOrMaps : [ mapOrMaps ]; + const matches: MetadataValue[] = []; + for (const mdMap of mdMaps) { + for (const mdKey of Metadata.resolveKeys(mdMap, keyOrKeys)) { + const candidates = mdMap[mdKey]; + if (candidates) { + for (const candidate of candidates) { + if (Metadata.valueMatches(candidate, filter)) { + matches.push(candidate); + } + } + } + } + if (!isEmpty(matches)) { + return matches; + } + } + return matches; + } + + /** + * Like [[Metadata.all]], but only returns string values. + * + * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). When multiple maps are given, they will be + * checked in order, and only values from the first with at least one match will be returned. + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string[]} the matching string values or an empty array. + */ + public static allValues(mapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): string[] { + return Metadata.all(mapOrMaps, keyOrKeys, filter).map((mdValue) => mdValue.value); + } + + /** + * Gets the first matching MetadataValue object in the map(s), or `undefined`. + * + * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {MetadataValue} the first matching value, or `undefined`. + */ + public static first(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): MetadataValue { + const mdMaps: MetadataMap[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [ mdMapOrMaps ]; + for (const mdMap of mdMaps) { + for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) { + const values: MetadataValue[] = mdMap[key]; + if (values) { + return values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); + } + } + } + } + + /** + * Like [[Metadata.first]], but only returns a string value, or `undefined`. + * + * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {string} the first matching string value, or `undefined`. + */ + public static firstValue(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): string { + const value = Metadata.first(mdMapOrMaps, keyOrKeys, filter); + return isUndefined(value) ? undefined : value.value; + } + + /** + * Checks for a matching metadata value in the given map(s). + * + * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @returns {boolean} whether a match is found. + */ + public static has(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + filter?: MetadataValueFilter): boolean { + return isNotUndefined(Metadata.first(mdMapOrMaps, keyOrKeys, filter)); + } + + /** + * Checks if a value matches a filter. + * + * @param {MetadataValue} mdValue the value to check. + * @param {MetadataValueFilter} filter the filter to use. + * @returns {boolean} whether the filter matches, or true if no filter is given. + */ + public static valueMatches(mdValue: MetadataValue, filter: MetadataValueFilter) { + if (!filter) { + return true; + } else if (filter.language && filter.language !== mdValue.language) { + return false; + } else if (filter.value) { + let fValue = filter.value; + let mValue = mdValue.value; + if (filter.ignoreCase) { + fValue = filter.value.toLowerCase(); + mValue = mdValue.value.toLowerCase(); + } + if (filter.substring) { + return mValue.includes(fValue); + } else { + return mValue === fValue; + } + } + return true; + } + + /** + * Gets the list of keys in the map limited by, and in the order given by `keyOrKeys`. + * + * @param {MetadataMap} mdMap The source map. + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + */ + private static resolveKeys(mdMap: MetadataMap, keyOrKeys: string | string[]): string[] { + const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [ keyOrKeys ]; + const outputKeys: string[] = []; + for (const inputKey of inputKeys) { + if (inputKey.includes('*')) { + const inputKeyRegex = new RegExp('^' + inputKey.replace('.', '\.').replace('*', '.*') + '$'); + for (const mapKey of Object.keys(mdMap)) { + if (!outputKeys.includes(mapKey) && inputKeyRegex.test(mapKey)) { + outputKeys.push(mapKey); + } + } + } else if (mdMap.hasOwnProperty(inputKey) && !outputKeys.includes(inputKey)) { + outputKeys.push(inputKey); + } + } + return outputKeys; + } +} diff --git a/src/app/core/shared/metadatum.model.ts b/src/app/core/shared/metadatum.model.ts deleted file mode 100644 index 02f6e00765..0000000000 --- a/src/app/core/shared/metadatum.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { autoserialize } from 'cerialize'; -import * as uuidv4 from 'uuid/v4'; - -export class Metadatum { - - uuid: string = uuidv4(); - /** - * The metadata field of this Metadatum - */ - @autoserialize - key: string; - - /** - * The language of this Metadatum - */ - @autoserialize - language: string; - - /** - * The value of this Metadatum - */ - @autoserialize - value: string; -} diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 41ed49b185..5086976f8b 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -97,7 +97,7 @@ describe('Core Module - RxJS Operators', () => { scheduler.schedule(() => source.pipe(getRequestFromRequestUUID(requestService)).subscribe()); scheduler.flush(); - expect(requestService.getByUUID).toHaveBeenCalledWith(testRequestUUID); + expect(requestService.getByUUID).toHaveBeenCalledWith(testRequestUUID) }); it('shouldn\'t return anything if there is no request matching the request uuid', () => { diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 54d409d751..df6226aedc 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -51,28 +51,18 @@ export class NavbarComponent extends MenuComponent implements OnInit { } as TextMenuItemModel, index: 0 }, + // { + // id: 'browse_global_communities_and_collections', + // parentID: 'browse_global', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.browse_global_communities_and_collections', + // link: '#' + // } as LinkMenuItemModel, + // }, { - id: 'browse_global_communities_and_collections', - parentID: 'browse_global', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.browse_global_communities_and_collections', - link: '#' - } as LinkMenuItemModel, - }, - { - id: 'browse_global_global_by_issue_date', - parentID: 'browse_global', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.browse_global_by_issue_date', - link: '#' - } as LinkMenuItemModel, - }, { id: 'browse_global_global_by_title', parentID: 'browse_global', active: false, @@ -94,6 +84,17 @@ export class NavbarComponent extends MenuComponent implements OnInit { link: '/browse/author' } as LinkMenuItemModel, }, + { + id: 'browse_global_by_subject', + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_subject', + link: '/browse/subject' + } as LinkMenuItemModel, + }, /* Statistics */ { diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index f30c5b905c..fc3300ae72 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -1,5 +1,5 @@ -

{{title}}

+

{{title | translate}}

- + +
diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 94cf81f46e..2e9e825a6b 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -5,7 +5,6 @@ import { PaginationComponentOptions } from '../pagination/pagination-component-o import { SortOptions } from '../../core/cache/models/sort-options.model'; import { fadeIn, fadeInOut } from '../animations/fade'; import { Observable } from 'rxjs'; -import { Item } from '../../core/shared/item.model'; import { ListableObject } from '../object-collection/shared/listable-object.model'; @Component({ @@ -21,10 +20,23 @@ import { ListableObject } from '../object-collection/shared/listable-object.mode * Component to display a browse-by page for any ListableObject */ export class BrowseByComponent { + /** + * The i18n message to display as title + */ @Input() title: string; + + /** + * The list of objects to display + */ @Input() objects$: Observable>>; + + /** + * The pagination configuration used for the list + */ @Input() paginationConfig: PaginationComponentOptions; + + /** + * The sorting configuration used for the list + */ @Input() sortConfig: SortOptions; - @Input() currentUrl: string; - query: string; } diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts index 51c5928348..3f52f0e46a 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts @@ -11,7 +11,6 @@ import { ResourceType } from '../../../core/shared/resource-type'; import { ComColFormComponent } from './comcol-form.component'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { hasValue } from '../../empty.util'; -import { Metadatum } from '../../../core/shared/metadatum.model'; describe('ComColFormComponent', () => { let comp: ComColFormComponent; @@ -29,23 +28,24 @@ describe('ComColFormComponent', () => { return undefined; } }; - const titleMD = { key: 'dc.title', value: 'Community Title' } as Metadatum; - const randomMD = { key: 'dc.random', value: 'Random metadata excluded from form' } as Metadatum; - const abstractMD = { - key: 'dc.description.abstract', - value: 'Community description' - } as Metadatum; - const newTitleMD = { key: 'dc.title', value: 'New Community Title' } as Metadatum; + const dcTitle = 'dc.title'; + const dcRandom = 'dc.random'; + const dcAbstract = 'dc.description.abstract'; + + const titleMD = { [dcTitle]: [ { value: 'Community Title', language: null } ] }; + const randomMD = { [dcRandom]: [ { value: 'Random metadata excluded from form', language: null } ] }; + const abstractMD = { [dcAbstract]: [ { value: 'Community description', language: null } ] }; + const newTitleMD = { [dcTitle]: [ { value: 'New Community Title', language: null } ] }; const formModel = [ new DynamicInputModel({ id: 'title', - name: newTitleMD.key, - value: 'New Community Title' + name: dcTitle, + value: newTitleMD[dcTitle][0].value }), new DynamicInputModel({ id: 'abstract', - name: abstractMD.key, - value: abstractMD.value + name: dcAbstract, + value: abstractMD[dcAbstract][0].value }) ]; @@ -84,10 +84,10 @@ describe('ComColFormComponent', () => { comp.dso = Object.assign( new Community(), { - metadata: [ - titleMD, - randomMD - ] + metadata: { + ...titleMD, + ...randomMD + } } ); @@ -98,11 +98,11 @@ describe('ComColFormComponent', () => { {}, new Community(), { - metadata: [ - randomMD, - newTitleMD, - abstractMD - ], + metadata: { + ...newTitleMD, + ...randomMD, + ...abstractMD + }, type: ResourceType.Community }, ) diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts index a7d638e791..6b28579491 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts @@ -8,6 +8,7 @@ import { FormGroup } from '@angular/forms'; import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; import { TranslateService } from '@ngx-translate/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.interfaces'; import { isNotEmpty } from '../../empty.util'; import { ResourceType } from '../../../core/shared/resource-type'; @@ -64,7 +65,7 @@ export class ComColFormComponent implements OnInit { ngOnInit(): void { this.formModel.forEach( (fieldModel: DynamicInputModel) => { - fieldModel.value = this.dso.findMetadata(fieldModel.name); + fieldModel.value = this.dso.firstMetadataValue(fieldModel.name); } ); this.formGroup = this.formService.createFormGroup(this.formModel); @@ -77,20 +78,24 @@ export class ComColFormComponent implements OnInit { } /** - * Checks which new fields where added and sends the updated version of the DSO to the parent component + * Checks which new fields were added and sends the updated version of the DSO to the parent component */ onSubmit() { - const metadata = this.formModel.map( - (fieldModel: DynamicInputModel) => { - return { key: fieldModel.name, value: fieldModel.value } + const formMetadata = new Object() as MetadataMap; + this.formModel.forEach((fieldModel: DynamicInputModel) => { + const value: MetadataValue = { value: fieldModel.value as string, language: null }; + if (formMetadata.hasOwnProperty(fieldModel.name)) { + formMetadata[fieldModel.name].push(value); + } else { + formMetadata[fieldModel.name] = [ value ]; } - ); - const filteredOldMetadata = this.dso.metadata.filter((filter) => !metadata.map((md) => md.key).includes(filter.key)); - const filteredNewMetadata = metadata.filter((md) => isNotEmpty(md.value)); + }); - const newMetadata = [...filteredOldMetadata, ...filteredNewMetadata]; const updatedDSO = Object.assign({}, this.dso, { - metadata: newMetadata, + metadata: { + ...this.dso.metadata, + ...formMetadata + }, type: ResourceType.Community }); this.submitForm.emit(updatedDSO); diff --git a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html new file mode 100644 index 0000000000..653bd1ed53 --- /dev/null +++ b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html @@ -0,0 +1,6 @@ +

{{'browse.comcol.head' | translate}}

+ diff --git a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts new file mode 100644 index 0000000000..85d40a77e0 --- /dev/null +++ b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -0,0 +1,16 @@ +import { Component, Input } from '@angular/core'; + +/** + * A component to display the "Browse By" section of a Community or Collection page + * It expects the ID of the Community or Collection as input to be passed on as a scope + */ +@Component({ + selector: 'ds-comcol-page-browse-by', + templateUrl: './comcol-page-browse-by.component.html', +}) +export class ComcolPageBrowseByComponent { + /** + * The ID of the Community or Collection + */ + @Input() id: string; +} diff --git a/src/app/shared/comcol-page-content/comcol-page-content.component.html b/src/app/shared/comcol-page-content/comcol-page-content.component.html index 4a0be8cfc7..16b20cde20 100644 --- a/src/app/shared/comcol-page-content/comcol-page-content.component.html +++ b/src/app/shared/comcol-page-content/comcol-page-content.component.html @@ -1,5 +1,5 @@ -
+

{{ title | translate }}

{{content}}
-
\ No newline at end of file +
diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index a7561af9d2..5c1b131e80 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -237,10 +237,9 @@ export class FormComponent implements OnDestroy, OnInit { private keepSync(): void { this.subs.push(this.formService.getFormData(this.formId) .subscribe((stateFormData) => { - // if (!Object.is(stateFormData, this.formGroup.value) && this.formGroup) { - // this.formGroup.setValue(stateFormData); - console.log(stateFormData); - // } + if (!Object.is(stateFormData, this.formGroup.value) && this.formGroup) { + this.formGroup.setValue(stateFormData); + } })); } diff --git a/src/app/shared/mocks/mock-item.ts b/src/app/shared/mocks/mock-item.ts index 2e5c764ee2..98881436b9 100644 --- a/src/app/shared/mocks/mock-item.ts +++ b/src/app/shared/mocks/mock-item.ts @@ -51,13 +51,14 @@ export const MockItem: Item = Object.assign(new Item(), { id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', type: 'bitstream', - metadata: [ - { - key: 'dc.title', - language: null, - value: 'test_word.docx' - } - ] + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx' + } + ] + } }, { sizeBytes: 31302, @@ -85,13 +86,14 @@ export const MockItem: Item = Object.assign(new Item(), { id: '99b00f3c-1cc6-4689-8158-91965bee6b28', uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', type: 'bitstream', - metadata: [ - { - key: 'dc.title', - language: null, - value: 'test_pdf.pdf' - } - ] + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_pdf.pdf' + } + ] + } } ] } @@ -100,98 +102,106 @@ export const MockItem: Item = Object.assign(new Item(), { id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', type: 'item', - metadata: [ - { - key: 'dc.creator', - language: 'en_US', - value: 'Doe, Jane' - }, - { - key: 'dc.date.accessioned', - language: null, - value: '1650-06-26T19:58:25Z' - }, - { - key: 'dc.date.available', - language: null, - value: '1650-06-26T19:58:25Z' - }, - { - key: 'dc.date.issued', - language: null, - value: '1650-06-26' - }, - { - key: 'dc.identifier.issn', - language: 'en_US', - value: '123456789' - }, - { - key: 'dc.identifier.uri', - language: null, - value: 'http://dspace7.4science.it/xmlui/handle/10673/6' - }, - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' - }, - { - key: 'dc.description.provenance', - language: 'en', - value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' - }, - { - key: 'dc.language', - language: 'en_US', - value: 'en' - }, - { - key: 'dc.rights', - language: 'en_US', - value: '© Jane Doe' - }, - { - key: 'dc.subject', - language: 'en_US', - value: 'keyword1' - }, - { - key: 'dc.subject', - language: 'en_US', - value: 'keyword2' - }, - { - key: 'dc.subject', - language: 'en_US', - value: 'keyword3' - }, - { - key: 'dc.title', - language: 'en_US', - value: 'Test PowerPoint Document' - }, - { - key: 'dc.type', - language: 'en_US', - value: 'text' - } - ], + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Test PowerPoint Document' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + }, owningCollection: observableOf({ self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', requestPending: false, diff --git a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts index 59df86fdff..d52036b5dc 100644 --- a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts +++ b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts @@ -1,6 +1,5 @@ import { Component, Inject } from '@angular/core'; import { ListableObject } from '../listable-object.model'; -import { hasValue } from '../../../empty.util'; @Component({ selector: 'ds-abstract-object-element', @@ -11,8 +10,4 @@ export class AbstractListableElementComponent { public constructor(@Inject('objectElementProvider') public listableObject: ListableObject) { this.object = listableObject as T; } - - hasValue(data) { - return hasValue(data); - } } diff --git a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts index cc72ff3043..66807c6b20 100644 --- a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.spec.ts @@ -8,21 +8,25 @@ let collectionGridElementComponent: CollectionGridElementComponent; let fixture: ComponentFixture; const mockCollectionWithAbstract: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCollectionWithoutAbstract: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionGridElementComponent', () => { diff --git a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts index dabb137ea7..bb6c81144a 100644 --- a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.spec.ts @@ -8,21 +8,25 @@ let communityGridElementComponent: CommunityGridElementComponent; let fixture: ComponentFixture; const mockCommunityWithAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunityGridElementComponent', () => { diff --git a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html index 728dba7549..6bb2cfa99d 100644 --- a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html +++ b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html @@ -5,19 +5,19 @@
-

{{object.findMetadata('dc.title')}}

+

{{object.firstMetadataValue('dc.title')}}

-

- {{authorMd.value}} +

+ {{author}} ; - {{object.findMetadata("dc.date.issued")}} + {{object.firstMetadataValue("dc.date.issued")}}

-

{{object.findMetadata("dc.description.abstract") }}

+

{{object.firstMetadataValue("dc.description.abstract")}}

diff --git a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts index f2aa594296..7b286cc415 100644 --- a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.spec.ts @@ -11,31 +11,37 @@ let fixture: ComponentFixture; const mockItemWithAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts index 8e6ff0696f..e8f8b1330e 100644 --- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCollectionWithAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithAbstract.hitHighlights = []; +mockCollectionWithAbstract.hitHighlights = {}; mockCollectionWithAbstract.dspaceObject = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - } ] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCollectionWithoutAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithoutAbstract.hitHighlights = []; +mockCollectionWithoutAbstract.hitHighlights = {}; mockCollectionWithoutAbstract.dspaceObject = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionSearchResultGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts index df8fdf026d..e111e624c5 100644 --- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCommunityWithAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithAbstract.hitHighlights = []; +mockCommunityWithAbstract.hitHighlights = {}; mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - } ] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithoutAbstract.hitHighlights = []; +mockCommunityWithoutAbstract.hitHighlights = {}; mockCommunityWithoutAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunitySearchResultGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html index 1e4f6f3c64..c7e2f524f3 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.html @@ -8,20 +8,20 @@
-

+

-

- {{dso.findMetadata("dc.date.issued")}} - , - + {{dso.firstMetadataValue('dc.date.issued')}} + , +

- +

diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts index ecc218f11d..0103fa5c49 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.spec.ts @@ -17,37 +17,43 @@ const truncatableServiceStub: any = { }; const mockItemWithAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithAuthorAndDate.hitHighlights = []; +mockItemWithAuthorAndDate.hitHighlights = {}; mockItemWithAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithoutAuthorAndDate.hitHighlights = []; +mockItemWithoutAuthorAndDate.hitHighlights = {}; mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemSearchResultGridElementComponent', () => { diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts index 5fd1c87edd..0901b7b8cc 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts @@ -2,12 +2,11 @@ import { Component, Inject } from '@angular/core'; import { SearchResult } from '../../../+search-page/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Metadatum } from '../../../core/shared/metadatum.model'; -import { isEmpty, hasNoValue, hasValue } from '../../empty.util'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { ListableObject } from '../../object-collection/shared/listable-object.model'; import { TruncatableService } from '../../truncatable/truncatable.service'; import { Observable } from 'rxjs'; +import { Metadata } from '../../../core/shared/metadata.model'; @Component({ selector: 'ds-search-result-grid-element', @@ -22,39 +21,24 @@ export class SearchResultGridElementComponent, K exten this.dso = this.object.dspaceObject; } - getValues(keys: string[]): string[] { - const results: string[] = new Array(); - this.object.hitHighlights.forEach( - (md: Metadatum) => { - if (keys.indexOf(md.key) > -1) { - results.push(md.value); - } - } - ); - if (isEmpty(results)) { - this.dso.filterMetadata(keys).forEach( - (md: Metadatum) => { - results.push(md.value); - } - ); - } - return results; + /** + * Gets all matching metadata string values from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string[]} the matching string values or an empty array. + */ + allMetadataValues(keyOrKeys: string | string[]): string[] { + return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } - getFirstValue(key: string): string { - let result: string; - this.object.hitHighlights.some( - (md: Metadatum) => { - if (key === md.key) { - result = md.value; - return true; - } - } - ); - if (hasNoValue(result)) { - result = this.dso.findMetadata(key); - } - return result; + /** + * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string} the first matching string value, or `undefined`. + */ + firstMetadataValue(keyOrKeys: string | string[]): string { + return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } isCollapsed(): Observable { diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html index 198e79b453..6139e4a9df 100644 --- a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html @@ -1,5 +1,5 @@
- + {{object.value}}   diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts index de53f2e095..54b58e131a 100644 --- a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts @@ -2,7 +2,6 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; import { TruncatePipe } from '../../utils/truncate.pipe'; -import { Metadatum } from '../../../core/shared/metadatum.model'; import { BrowseEntryListElementComponent } from './browse-entry-list-element.component'; import { BrowseEntry } from '../../../core/shared/browse-entry.model'; @@ -33,7 +32,7 @@ describe('MetadataListElementComponent', () => { browseEntryListElementComponent = fixture.componentInstance; })); - describe('When the metadatum is loaded', () => { + describe('When the metadata is loaded', () => { beforeEach(() => { browseEntryListElementComponent.object = mockValue; fixture.detectChanges(); diff --git a/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts b/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts index a31af1e50c..bde6b4b97a 100644 --- a/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts +++ b/src/app/shared/object-list/collection-list-element/collection-list-element.component.spec.ts @@ -8,21 +8,25 @@ let collectionListElementComponent: CollectionListElementComponent; let fixture: ComponentFixture; const mockCollectionWithAbstract: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCollectionWithoutAbstract: Collection = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionListElementComponent', () => { diff --git a/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts b/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts index 08147d8573..46ba27eb00 100644 --- a/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts +++ b/src/app/shared/object-list/community-list-element/community-list-element.component.spec.ts @@ -8,21 +8,25 @@ let communityListElementComponent: CommunityListElementComponent; let fixture: ComponentFixture; const mockCommunityWithAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - }] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: Community = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunityListElementComponent', () => { diff --git a/src/app/shared/object-list/item-list-element/item-list-element.component.html b/src/app/shared/object-list/item-list-element/item-list-element.component.html index 711ce19037..8179b77629 100644 --- a/src/app/shared/object-list/item-list-element/item-list-element.component.html +++ b/src/app/shared/object-list/item-list-element/item-list-element.component.html @@ -1,23 +1,23 @@ - {{object.findMetadata("dc.title")}} + {{object.firstMetadataValue("dc.title")}}
- - {{authorMd.value}} + {{author}} ; - ({{object.findMetadata("dc.publisher")}}, {{object.findMetadata("dc.date.issued")}}) + ({{object.firstMetadataValue("dc.publisher")}}, {{object.firstMetadataValue("dc.date.issued")}}) -
- {{object.findMetadata("dc.description.abstract")}} +
+ {{object.firstMetadataValue("dc.description.abstract")}}
diff --git a/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts index 64108fd5b0..392d81bee4 100644 --- a/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts +++ b/src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts @@ -11,31 +11,37 @@ let fixture: ComponentFixture; const mockItemWithAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: Item = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html index be549b2b76..b4af631e83 100644 --- a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.html @@ -1,2 +1,2 @@ - -
+ +
diff --git a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts index 2ffaf38b53..e897071a00 100644 --- a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCollectionWithAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithAbstract.hitHighlights = []; +mockCollectionWithAbstract.hitHighlights = {}; mockCollectionWithAbstract.dspaceObject = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - } ] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCollectionWithoutAbstract: CollectionSearchResult = new CollectionSearchResult(); -mockCollectionWithoutAbstract.hitHighlights = []; +mockCollectionWithoutAbstract.hitHighlights = {}; mockCollectionWithoutAbstract.dspaceObject = Object.assign(new Collection(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CollectionSearchResultListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html index 150ca503cc..9444a63771 100644 --- a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.html @@ -1,2 +1,2 @@ - -
+ +
diff --git a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts index 70877d0744..75d5966767 100644 --- a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.spec.ts @@ -16,25 +16,29 @@ const truncatableServiceStub: any = { }; const mockCommunityWithAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithAbstract.hitHighlights = []; +mockCommunityWithAbstract.hitHighlights = {}; mockCommunityWithAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.description.abstract', - language: 'en_US', - value: 'Short description' - } ] + metadata: { + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'Short description' + } + ] + } }); const mockCommunityWithoutAbstract: CommunitySearchResult = new CommunitySearchResult(); -mockCommunityWithoutAbstract.hitHighlights = []; +mockCommunityWithoutAbstract.hitHighlights = {}; mockCommunityWithoutAbstract.dspaceObject = Object.assign(new Community(), { - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Test title' - } ] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'Test title' + } + ] + } }); describe('CommunitySearchResultListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html index 584d476e73..6261220459 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html @@ -1,24 +1,24 @@ + [innerHTML]="firstMetadataValue('dc.title')"> - () - ) + - + -
+
+ [innerHTML]="firstMetadataValue('dc.description.abstract')">
- \ No newline at end of file + diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts index bdc8ebcecf..8567fc1782 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts @@ -17,37 +17,43 @@ const truncatableServiceStub: any = { }; const mockItemWithAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithAuthorAndDate.hitHighlights = []; +mockItemWithAuthorAndDate.hitHighlights = {}; mockItemWithAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.contributor.author', - language: 'en_US', - value: 'Smith, Donald' - }, - { - key: 'dc.date.issued', - language: null, - value: '2015-06-26' - }] + metadata: { + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } }); const mockItemWithoutAuthorAndDate: ItemSearchResult = new ItemSearchResult(); -mockItemWithoutAuthorAndDate.hitHighlights = []; +mockItemWithoutAuthorAndDate.hitHighlights = {}; mockItemWithoutAuthorAndDate.dspaceObject = Object.assign(new Item(), { bitstreams: observableOf({}), - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'This is just another title' - }, - { - key: 'dc.type', - language: null, - value: 'Article' - }] + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ] + } }); describe('ItemSearchResultListElementComponent', () => { diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index 6a3b698dd6..2a16b0b754 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -3,11 +3,10 @@ import { Observable } from 'rxjs'; import { SearchResult } from '../../../+search-page/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Metadatum } from '../../../core/shared/metadatum.model'; -import { hasNoValue, isEmpty } from '../../empty.util'; import { ListableObject } from '../../object-collection/shared/listable-object.model'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { TruncatableService } from '../../truncatable/truncatable.service'; +import { Metadata } from '../../../core/shared/metadata.model'; @Component({ selector: 'ds-search-result-list-element', @@ -22,39 +21,24 @@ export class SearchResultListElementComponent, K exten this.dso = this.object.dspaceObject; } - getValues(keys: string[]): string[] { - const results: string[] = new Array(); - this.object.hitHighlights.forEach( - (md: Metadatum) => { - if (keys.indexOf(md.key) > -1) { - results.push(md.value); - } - } - ); - if (isEmpty(results)) { - this.dso.filterMetadata(keys).forEach( - (md: Metadatum) => { - results.push(md.value); - } - ); - } - return results; + /** + * Gets all matching metadata string values from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string[]} the matching string values or an empty array. + */ + allMetadataValues(keyOrKeys: string | string[]): string[] { + return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } - getFirstValue(key: string): string { - let result: string; - this.object.hitHighlights.some( - (md: Metadatum) => { - if (key === md.key) { - result = md.value; - return true; - } - } - ); - if (hasNoValue(result)) { - result = this.dso.findMetadata(key); - } - return result; + /** + * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. + * + * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @returns {string} the first matching string value, or `undefined`. + */ + firstMetadataValue(keyOrKeys: string | string[]): string { + return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); } isCollapsed(): Observable { diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 004d0c5b21..b164abee1f 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -30,6 +30,7 @@ describe('SearchFormComponent', () => { }); it('should display scopes when available with default and all scopes', () => { + comp.scopes = objects; fixture.detectChanges(); const select: HTMLElement = de.query(By.css('select')).nativeElement; @@ -121,33 +122,38 @@ export const objects: DSpaceObject[] = [ id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', type: ResourceType.Community, - metadata: [ - { - key: 'dc.description', - language: null, - value: '' - }, - { - key: 'dc.description.abstract', - language: null, - value: 'This is a test community to hold content for the OR2017 demostration' - }, - { - key: 'dc.description.tableofcontents', - language: null, - value: '' - }, - { - key: 'dc.rights', - language: null, - value: '' - }, - { - key: 'dc.title', - language: null, - value: 'OR2017 - Demonstration' - } - ] + metadata: { + 'dc.description': [ + { + language: null, + value: '' + } + ], + 'dc.description.abstract': [ + { + language: null, + value: 'This is a test community to hold content for the OR2017 demostration' + } + ], + 'dc.description.tableofcontents': [ + { + language: null, + value: '' + } + ], + 'dc.rights': [ + { + language: null, + value: '' + } + ], + 'dc.title': [ + { + language: null, + value: 'OR2017 - Demonstration' + } + ] + } }), Object.assign(new Community(), { @@ -170,33 +176,38 @@ export const objects: DSpaceObject[] = [ id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', type: ResourceType.Community, - metadata: [ - { - key: 'dc.description', - language: null, - value: '

This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).

\r\n

DSpace Communities may contain one or more Sub-Communities or Collections (of Items).

\r\n

This particular Community has its own logo (the DuraSpace logo).

' - }, - { - key: 'dc.description.abstract', - language: null, - value: 'This is a sample top-level community' - }, - { - key: 'dc.description.tableofcontents', - language: null, - value: '

This is the news section for this Sample Community. System or Community Administrators (of this Community) can edit this News field.

' - }, - { - key: 'dc.rights', - language: null, - value: '

If this Community had special copyright text to display, it would be displayed here.

' - }, - { - key: 'dc.title', - language: null, - value: 'Sample Community' - } - ] + metadata: { + 'dc.description': [ + { + language: null, + value: '

This is the introductory text for the Sample Community on the DSpace Demonstration Site. It is editable by System or Community Administrators (of this Community).

\r\n

DSpace Communities may contain one or more Sub-Communities or Collections (of Items).

\r\n

This particular Community has its own logo (the DuraSpace logo).

' + } + ], + 'dc.description.abstract': [ + { + language: null, + value: 'This is a sample top-level community' + } + ], + 'dc.description.tableofcontents': [ + { + language: null, + value: '

This is the news section for this Sample Community. System or Community Administrators (of this Community) can edit this News field.

' + } + ], + 'dc.rights': [ + { + language: null, + value: '

If this Community had special copyright text to display, it would be displayed here.

' + } + ], + 'dc.title': [ + { + language: null, + value: 'Sample Community' + } + ] + } } ) ]; diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 3a2a676a1c..5ae3e517e3 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -94,6 +94,7 @@ import { LangSwitchComponent } from './lang-switch/lang-switch.component'; import { ObjectValuesPipe } from './utils/object-values-pipe'; import { InListValidator } from './utils/in-list-validator.directive'; import { AutoFocusDirective } from './utils/auto-focus.directive'; +import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -142,6 +143,7 @@ const COMPONENTS = [ CreateComColPageComponent, EditComColPageComponent, DeleteComColPageComponent, + ComcolPageBrowseByComponent, DsDynamicFormComponent, DsDynamicFormControlComponent, DsDynamicListComponent,