diff --git a/config/environment.default.js b/config/environment.default.js index cccb8e642c..f70f132fa4 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -141,5 +141,19 @@ module.exports = { code: 'nl', label: 'Nederlands', active: false, - }] + }], + // Browse-By Pages + browseBy: { + // Amount of years to display using jumps of one year (current year - oneYearLimit) + oneYearLimit: 10, + // Limit for years to display using jumps of five years (current year - fiveYearLimit) + fiveYearLimit: 30, + // The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) + defaultLowerLimit: 1900 + }, + item: { + edit: { + undoTimeout: 10000 // 10 seconds + } + } }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 4d95a0e1d1..e9534d3df9 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -123,6 +123,7 @@ "status": { "head": "Item Status", "description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.", + "title": "Item Edit - Status", "labels": { "id": "Item Internal ID", "handle": "Handle", @@ -165,16 +166,20 @@ } }, "bitstreams": { - "head": "Item Bitstreams" + "head": "Item Bitstreams", + "title": "Item Edit - Bitstreams" }, "metadata": { - "head": "Item Metadata" + "head": "Item Metadata", + "title": "Item Edit - Metadata" }, "view": { - "head": "View Item" + "head": "View Item", + "title": "Item Edit - View" }, "curate": { - "head": "Curate" + "head": "Curate", + "title": "Item Edit - Curate" } }, "modify.overview": { @@ -188,7 +193,7 @@ "confirm": "Withdraw", "cancel": "Cancel", "success": "The item was withdrawn successfully", - "error": "An error occured while withdrawing the item" + "error": "An error occurred while withdrawing the item" }, "reinstate": { "header": "Reinstate item: {{ id }}", @@ -196,7 +201,7 @@ "confirm": "Reinstate", "cancel": "Cancel", "success": "The item was reinstated successfully", - "error": "An error occured while reinstating the item" + "error": "An error occurred while reinstating the item" }, "private": { "header": "Make item private: {{ id }}", @@ -204,7 +209,7 @@ "confirm": "Make it Private", "cancel": "Cancel", "success": "The item is now private", - "error": "An error occured while making the item private" + "error": "An error occurred while making the item private" }, "public": { "header": "Make item public: {{ id }}", @@ -212,7 +217,7 @@ "confirm": "Make it Public", "cancel": "Cancel", "success": "The item is now public", - "error": "An error occured while making the item public" + "error": "An error occurred while making the item public" }, "delete": { "header": "Delete item: {{ id }}", @@ -220,7 +225,48 @@ "confirm": "Delete", "cancel": "Cancel", "success": "The item has been deleted", - "error": "An error occured while deleting the item" + "error": "An error occurred while deleting the item" + }, + "metadata": { + "add-button": "Add", + "discard-button": "Discard", + "reinstate-button": "Undo", + "save-button": "Save", + "headers": { + "field": "Field", + "value": "Value", + "language": "Lang", + "edit": "Edit" + }, + "edit": { + "buttons": { + "edit": "Edit", + "unedit": "Stop editing", + "remove": "Remove", + "undo": "Undo changes" + } + }, + "metadatafield": { + "invalid": "Please choose a valid metadata field" + }, + "notifications": { + "outdated": { + "title": "Changed outdated", + "content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts" + }, + "discarded": { + "title": "Changed discarded", + "content": "Your changes were discarded. To reinstate your changes click the 'Undo' button" + }, + "invalid": { + "title": "Metadata invalid", + "content": "Your changes were not saved. Please make sure all fields are valid before you save." + }, + "saved": { + "title": "Metadata saved", + "content": "Your changes to this item's metadata were saved." + } + } } } }, @@ -346,9 +392,6 @@ "title": "Settings", "sort-by": "Sort By", "rpp": "Results per page" - }, - "tab":{ - "title":"Show" } }, "switch-configuration": { @@ -424,15 +467,40 @@ }, "browse": { "title": "Browsing {{ collection }} by {{ field }} {{ value }}", + "startsWith": { + "jump": "Jump to a point in the index:", + "choose_year": "(Choose year)", + "choose_start": "(Choose start)", + "type_date": "Or type in a date (year-month):", + "type_text": "Or enter first few letters:", + "months": { + "none": "(Choose month)", + "january": "January", + "february": "February", + "march": "March", + "april": "April", + "may": "May", + "june": "June", + "july": "July", + "august": "August", + "september": "September", + "october": "October", + "november": "November", + "december": "December" + }, + "submit": "Go" + }, "metadata": { "title": "Title", "author": "Author", - "subject": "Subject" + "subject": "Subject", + "dateissued": "Issue Date" }, "comcol": { "head": "Browse", "by": { "title": "By Title", + "dateissued": "By Issue Date", "author": "By Author", "subject": "By Subject" } @@ -614,7 +682,8 @@ "objects": "Loading...", "search-results": "Loading search results...", "mydspace-results": "Loading items...", - "browse-by": "Loading items..." + "browse-by": "Loading items...", + "browse-by-page": "Loading page..." }, "error": { "default": "Error", @@ -727,7 +796,7 @@ "collection": "Collection", "no-collection": "No collection found", "search-collection": "Search for a collection", - + "save_error_notice": "There was an issue when saving the item, please try again later.", "deposit_success_notice": "Submission deposited successfully.", "deposit_error_notice": "There was an issue when submitting the item, please try again later.", "discard_success_notice": "Submission discarded successfully.", diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index 02cf168387..42b6d1f133 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -20,7 +20,8 @@ describe('MetadataSchemaFormComponent', () => { /* tslint:disable:no-empty */ const registryServiceStub = { getActiveMetadataSchema: () => observableOf(undefined), - createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema) + createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), + cancelEditMetadataSchema: () => {} }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 25502a27c8..4364b0234a 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -27,7 +27,8 @@ describe('MetadataFieldFormComponent', () => { /* tslint:disable:no-empty */ const registryServiceStub = { getActiveMetadataField: () => observableOf(undefined), - createOrUpdateMetadataField: (field: MetadataField) => observableOf(field) + createOrUpdateMetadataField: (field: MetadataField) => observableOf(field), + cancelEditMetadataSchema: () => {}, }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts new file mode 100644 index 0000000000..ccf7cde67b --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts @@ -0,0 +1,104 @@ +import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +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 { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../core/data/remote-data'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { Community } from '../../core/shared/community.model'; +import { Item } from '../../core/shared/item.model'; +import { ENV_CONFIG, GLOBAL_CONFIG } from '../../../config'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec'; + +describe('BrowseByDatePageComponent', () => { + let comp: BrowseByDatePageComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + metadata: [ + { + key: 'dc.title', + value: 'test community' + } + ] + }); + + const firstItem = Object.assign(new Item(), { + id: 'first-item-id', + metadata: { + 'dc.date.issued': [ + { + value: '1950-01-01' + } + ] + } + }); + + const mockBrowseService = { + getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), + getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), + getFirstItemFor: () => observableOf(new RemoteData(false, false, true, undefined, firstItem)) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}), + data: observableOf({ metadata: 'dateissued', metadataField: 'dc.date.issued' }) + }); + + const mockCdRef = Object.assign({ + detectChanges: () => fixture.detectChanges() + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByDatePageComponent, EnumKeysPipe], + providers: [ + { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() }, + { provide: ChangeDetectorRef, useValue: mockCdRef } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByDatePageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + }); + + it('should initialize the list of items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual([firstItem]); + }); + }); + + it('should create a list of startsWith options with the earliest year at the end (rounded down by 10)', () => { + expect(comp.startsWithOptions[comp.startsWithOptions.length - 1]).toEqual(1950); + }); + + it('should create a list of startsWith options with the current year first', () => { + expect(comp.startsWithOptions[0]).toEqual(new Date().getFullYear()); + }); +}); diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts new file mode 100644 index 0000000000..29a3f9ad31 --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -0,0 +1,116 @@ +import { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions +} from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { Item } from '../../core/shared/item.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; + +@Component({ + selector: 'ds-browse-by-date-page', + styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'], + templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html' +}) +/** + * Component for browsing items by metadata definition of type 'date' + * A metadata definition is a short term used to describe one or multiple metadata fields. + * An example would be 'dateissued' for 'dc.date.issued' + */ +export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { + + /** + * The default metadata-field to use for determining the lower limit of the StartsWith dropdown options + */ + defaultMetadataField = 'dc.date.issued'; + + public constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig, + protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router, + protected cdRef: ChangeDetectorRef) { + super(route, browseService, dsoService, router); + } + + ngOnInit(): void { + this.startsWithType = StartsWithType.date; + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); + }) + .subscribe((params) => { + const metadataField = params.metadataField || this.defaultMetadataField; + this.metadata = params.metadata || this.defaultMetadata; + this.startsWith = +params.startsWith || params.startsWith; + const searchOptions = browseParamsToOptions(params, Object.assign({}), this.sortConfig, this.metadata); + this.updatePageWithItems(searchOptions, this.value); + this.updateParent(params.scope); + this.updateStartsWithOptions(this.metadata, metadataField, params.scope); + })); + } + + /** + * Update the StartsWith options + * In this implementation, it creates a list of years starting from now, going all the way back to the earliest + * date found on an item within this scope. The further back in time, the bigger the change in years become to avoid + * extremely long lists with a one-year difference. + * To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this. + * @param definition The metadata definition to fetch the first item for + * @param metadataField The metadata field to fetch the earliest date from (expects a date field) + * @param scope The scope under which to fetch the earliest item for + */ + updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { + this.subs.push( + this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData) => { + let lowerLimit = this.config.browseBy.defaultLowerLimit; + if (hasValue(firstItemRD.payload)) { + const date = firstItemRD.payload.firstMetadataValue(metadataField); + if (hasValue(date) && hasValue(+date.split('-')[0])) { + lowerLimit = +date.split('-')[0]; + } + } + const options = []; + const currentYear = new Date().getFullYear(); + const oneYearBreak = Math.floor((currentYear - this.config.browseBy.oneYearLimit) / 5) * 5; + const fiveYearBreak = Math.floor((currentYear - this.config.browseBy.fiveYearLimit) / 10) * 10; + if (lowerLimit <= fiveYearBreak) { + lowerLimit -= 10; + } else if (lowerLimit <= oneYearBreak) { + lowerLimit -= 5; + } else { + lowerLimit -= 1; + } + let i = currentYear; + while (i > lowerLimit) { + options.push(i); + if (i <= fiveYearBreak) { + i -= 10; + } else if (i <= oneYearBreak) { + i -= 5; + } else { + i--; + } + } + if (isNotEmpty(options)) { + this.startsWithOptions = options; + this.cdRef.detectChanges(); + } + }) + ); + } + +} 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 index 08fb762db0..cf43f74eb0 100644 --- 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 @@ -1,10 +1,18 @@
-
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 index a53faf6b8b..98d7299984 100644 --- 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 @@ -19,6 +19,7 @@ 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'; +import { MockRouter } from '../../shared/mocks/mock-router'; describe('BrowseByMetadataPageComponent', () => { let comp: BrowseByMetadataPageComponent; @@ -86,7 +87,8 @@ describe('BrowseByMetadataPageComponent', () => { providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, - { provide: DSpaceObjectDataService, useValue: mockDsoService } + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); 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 index 87ccb20c0b..52c63341bd 100644 --- 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 @@ -4,17 +4,17 @@ 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 { ActivatedRoute, Router } 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'; +import { take } from 'rxjs/operators'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by-metadata-page', @@ -72,6 +72,18 @@ export class BrowseByMetadataPageComponent implements OnInit { */ metadata = this.defaultMetadata; + /** + * The type of StartsWith options to render + * Defaults to text + */ + startsWithType = StartsWithType.text; + + /** + * The list of StartsWith options + * Should be defined after ngOnInit is called! + */ + startsWithOptions; + /** * The value we're browing items for * - When the value is not empty, we're browsing items @@ -79,9 +91,15 @@ export class BrowseByMetadataPageComponent implements OnInit { */ value = ''; - public constructor(private route: ActivatedRoute, - private browseService: BrowseService, - private dsoService: DSpaceObjectDataService) { + /** + * The current startsWith option (fetched and updated from query-params) + */ + startsWith: string; + + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router) { } ngOnInit(): void { @@ -96,6 +114,7 @@ export class BrowseByMetadataPageComponent implements OnInit { .subscribe((params) => { this.metadata = params.metadata || this.defaultMetadata; this.value = +params.value || params.value || ''; + this.startsWith = +params.startsWith || params.startsWith; const searchOptions = browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata); if (isNotEmpty(this.value)) { this.updatePageWithItems(searchOptions, this.value); @@ -104,6 +123,15 @@ export class BrowseByMetadataPageComponent implements OnInit { } this.updateParent(params.scope); })); + this.updateStartsWithTextOptions(); + } + + /** + * Update the StartsWith options with text values + * It adds the value "0-9" as well as all letters from A to Z + */ + updateStartsWithTextOptions() { + this.startsWithOptions = ['0-9', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; } /** @@ -144,6 +172,58 @@ export class BrowseByMetadataPageComponent implements OnInit { } } + /** + * Navigate to the previous page + */ + goPrev() { + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getPrevBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getPrevBrowseEntries(entries); + }); + } + } + + /** + * Navigate to the next page + */ + goNext() { + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getNextBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getNextBrowseEntries(entries); + }); + } + } + + /** + * Change the page size + * @param size + */ + pageSizeChange(size) { + this.router.navigate([], { + queryParams: Object.assign({ pageSize: size }), + queryParamsHandling: 'merge' + }); + } + + /** + * Change the sorting direction + * @param direction + */ + sortDirectionChange(direction) { + this.router.navigate([], { + queryParams: Object.assign({ sortDirection: direction }), + queryParamsHandling: 'merge' + }); + } + ngOnDestroy(): void { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } @@ -177,6 +257,7 @@ export function browseParamsToOptions(params: any, field: params.sortField || sortConfig.field } ), + +params.startsWith || params.startsWith, 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 deleted file mode 100644 index 84b0baf1f6..0000000000 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
- - -
-
diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss deleted file mode 100644 index e69de29bb2..0000000000 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 index c92e5c64cb..855101bb9d 100644 --- 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 @@ -1,5 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } 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'; @@ -15,6 +15,8 @@ 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'; +import { BrowseService } from '../../core/browse/browse.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; describe('BrowseByTitlePageComponent', () => { let comp: BrowseByTitlePageComponent; @@ -44,8 +46,9 @@ describe('BrowseByTitlePageComponent', () => { }) ]; - const mockItemDataService = { - findAll: () => toRemoteData(mockItems) + const mockBrowseService = { + getBrowseItemsFor: () => toRemoteData(mockItems), + getBrowseEntriesFor: () => toRemoteData([]) }; const mockDsoService = { @@ -53,7 +56,8 @@ describe('BrowseByTitlePageComponent', () => { }; const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({}) + params: observableOf({}), + data: observableOf({ metadata: 'title' }) }); beforeEach(async(() => { @@ -62,8 +66,9 @@ describe('BrowseByTitlePageComponent', () => { declarations: [BrowseByTitlePageComponent, EnumKeysPipe], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, - { provide: ItemDataService, useValue: mockItemDataService }, - { provide: DSpaceObjectDataService, useValue: mockDsoService } + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); 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 6ba43c8f10..717275bf8b 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,107 +1,51 @@ -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 { combineLatest as observableCombineLatest } from 'rxjs'; +import { Component } from '@angular/core'; 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 } from '@angular/router'; +import { ActivatedRoute, Router } 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 { + BrowseByMetadataPageComponent, + 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'; +import { BrowseService } from '../../core/browse/browse.service'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-browse-by-title-page', - styleUrls: ['./browse-by-title-page.component.scss'], - templateUrl: './browse-by-title-page.component.html' + styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'], + templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html' }) /** * Component for browsing items by title (dc.title) */ -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 - }); - - /** - * 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) { +export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router) { + super(route, browseService, dsoService, router); } ngOnInit(): void { + this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); 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); + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); }) .subscribe((params) => { - this.updatePage(browseParamsToOptions(params, this.paginationConfig, this.sortConfig)); + this.metadata = params.metadata || this.defaultMetadata; + this.updatePageWithItems(browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata), undefined); this.updateParent(params.scope) })); - } - - /** - * Updates the current page with searchOptions - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - */ - updatePage(searchOptions: BrowseEntrySearchOptions) { - this.items$ = this.itemDataService.findAll({ - currentPage: searchOptions.pagination.currentPage, - elementsPerPage: searchOptions.pagination.pageSize, - 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() - ); - } + this.updateStartsWithTextOptions(); } ngOnDestroy(): void { diff --git a/src/app/+browse-by/browse-by-guard.ts b/src/app/+browse-by/browse-by-guard.ts new file mode 100644 index 0000000000..30f5d69ffc --- /dev/null +++ b/src/app/+browse-by/browse-by-guard.ts @@ -0,0 +1,58 @@ +import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; +import { Injectable } from '@angular/core'; +import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; +import { hasValue } from '../shared/empty.util'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable() +/** + * A guard taking care of the correct route.data being set for the Browse-By components + */ +export class BrowseByGuard implements CanActivate { + + constructor(protected dsoService: DSpaceObjectDataService, + protected translate: TranslateService) { + } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + const title = route.data.title; + const metadata = route.params.metadata || route.queryParams.metadata || route.data.metadata; + const metadataField = route.data.metadataField; + const scope = route.queryParams.scope; + const value = route.queryParams.value; + + const metadataTranslated$ = this.translate.get('browse.metadata.' + metadata).pipe(take(1)); + + if (hasValue(scope)) { + const dsoAndMetadata$ = observableCombineLatest(metadataTranslated$, this.dsoService.findById(scope).pipe(getSucceededRemoteData())); + return dsoAndMetadata$.pipe( + map(([metadataTranslated, dsoRD]) => { + const name = dsoRD.payload.name; + route.data = this.createData(title, metadata, metadataField, name, metadataTranslated, value);; + return true; + }) + ); + } else { + return metadataTranslated$.pipe( + map((metadataTranslated: string) => { + route.data = this.createData(title, metadata, metadataField, '', metadataTranslated, value); + return true; + }) + ) + } + } + + private createData(title, metadata, metadataField, collection, field, value) { + return { + title: title, + metadata: metadata, + metadataField: metadataField, + collection: collection, + field: field, + value: hasValue(value) ? `"${value}"` : '' + } + } +} diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts index 38915fffca..9ba15ecfe9 100644 --- a/src/app/+browse-by/browse-by-routing.module.ts +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -2,12 +2,15 @@ import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; +import { BrowseByGuard } from './browse-by-guard'; @NgModule({ imports: [ RouterModule.forChild([ - { path: 'title', component: BrowseByTitlePageComponent }, - { path: ':metadata', component: BrowseByMetadataPageComponent } + { path: 'title', component: BrowseByTitlePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'title', title: 'browse.title' } }, + { path: 'dateissued', component: BrowseByDatePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'dateissued', metadataField: 'dc.date.issued', title: 'browse.title' } }, + { path: ':metadata', component: BrowseByMetadataPageComponent, canActivate: [BrowseByGuard], data: { title: 'browse.title' } } ]) ] }) diff --git a/src/app/+browse-by/browse-by.module.ts b/src/app/+browse-by/browse-by.module.ts index 38e5001b80..30d4617c16 100644 --- a/src/app/+browse-by/browse-by.module.ts +++ b/src/app/+browse-by/browse-by.module.ts @@ -6,6 +6,8 @@ import { SharedModule } from '../shared/shared.module'; import { BrowseByRoutingModule } from './browse-by-routing.module'; import { BrowseService } from '../core/browse/browse.service'; import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; +import { BrowseByGuard } from './browse-by-guard'; @NgModule({ imports: [ @@ -15,11 +17,13 @@ import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse ], declarations: [ BrowseByTitlePageComponent, - BrowseByMetadataPageComponent + BrowseByMetadataPageComponent, + BrowseByDatePageComponent ], providers: [ ItemDataService, - BrowseService + BrowseService, + BrowseByGuard ] }) export class BrowseByModule { diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts index 52497694b9..94229b4932 100644 --- a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts @@ -3,7 +3,6 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { RouteService } from '../../shared/services/route.service'; import { Router } from '@angular/router'; import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; -import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model'; import { Collection } from '../../core/shared/collection.model'; import { CollectionDataService } from '../../core/data/collection-data.service'; @@ -15,7 +14,7 @@ import { CollectionDataService } from '../../core/data/collection-data.service'; styleUrls: ['./create-collection-page.component.scss'], templateUrl: './create-collection-page.component.html' }) -export class CreateCollectionPageComponent extends CreateComColPageComponent { +export class CreateCollectionPageComponent extends CreateComColPageComponent { protected frontendURL = '/collections/'; public constructor( diff --git a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts index 80abb83694..5f2bd89942 100644 --- a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts +++ b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts @@ -1,12 +1,8 @@ import { Component } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model'; import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; -import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model'; import { Collection } from '../../core/shared/collection.model'; import { TranslateService } from '@ngx-translate/core'; @@ -18,7 +14,7 @@ import { TranslateService } from '@ngx-translate/core'; styleUrls: ['./delete-collection-page.component.scss'], templateUrl: './delete-collection-page.component.html' }) -export class DeleteCollectionPageComponent extends DeleteComColPageComponent { +export class DeleteCollectionPageComponent extends DeleteComColPageComponent { protected frontendURL = '/collections/'; public constructor( diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts index 9bbdbfb9a1..a3978a5e43 100644 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts @@ -13,7 +13,7 @@ import { CollectionDataService } from '../../core/data/collection-data.service'; styleUrls: ['./edit-collection-page.component.scss'], templateUrl: './edit-collection-page.component.html' }) -export class EditCollectionPageComponent extends EditComColPageComponent { +export class EditCollectionPageComponent extends EditComColPageComponent { protected frontendURL = '/collections/'; public constructor( diff --git a/src/app/+community-page/create-community-page/create-community-page.component.ts b/src/app/+community-page/create-community-page/create-community-page.component.ts index 47fb065038..828d8338af 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.ts +++ b/src/app/+community-page/create-community-page/create-community-page.component.ts @@ -4,7 +4,6 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { RouteService } from '../../shared/services/route.service'; import { Router } from '@angular/router'; import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; -import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model'; /** * Component that represents the page where a user can create a new Community @@ -14,7 +13,7 @@ import { NormalizedCommunity } from '../../core/cache/models/normalized-communit styleUrls: ['./create-community-page.component.scss'], templateUrl: './create-community-page.component.html' }) -export class CreateCommunityPageComponent extends CreateComColPageComponent { +export class CreateCommunityPageComponent extends CreateComColPageComponent { protected frontendURL = '/communities/'; public constructor( diff --git a/src/app/+community-page/delete-community-page/delete-community-page.component.ts b/src/app/+community-page/delete-community-page/delete-community-page.component.ts index 01741a7577..9f1465a3c7 100644 --- a/src/app/+community-page/delete-community-page/delete-community-page.component.ts +++ b/src/app/+community-page/delete-community-page/delete-community-page.component.ts @@ -2,7 +2,6 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model'; import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; @@ -15,7 +14,7 @@ import { TranslateService } from '@ngx-translate/core'; styleUrls: ['./delete-community-page.component.scss'], templateUrl: './delete-community-page.component.html' }) -export class DeleteCommunityPageComponent extends DeleteComColPageComponent { +export class DeleteCommunityPageComponent extends DeleteComColPageComponent { protected frontendURL = '/communities/'; public constructor( diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.ts index 68f092e915..9f49ac49dd 100644 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.ts @@ -2,7 +2,6 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model'; import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; /** @@ -13,7 +12,7 @@ import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-p styleUrls: ['./edit-community-page.component.scss'], templateUrl: './edit-community-page.component.html' }) -export class EditCommunityPageComponent extends EditComColPageComponent { +export class EditCommunityPageComponent extends EditComColPageComponent { protected frontendURL = '/communities/'; public constructor( diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.html b/src/app/+item-page/edit-item-page/edit-item-page.component.html index 001b484c2c..ca1c809cd9 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.html +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.html @@ -1,36 +1,24 @@
-
-
-

{{'item.edit.head' | translate}}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+

{{'item.edit.head' | translate}}

+ +
-
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.scss b/src/app/+item-page/edit-item-page/edit-item-page.component.scss new file mode 100644 index 0000000000..f22ca8f8de --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.scss @@ -0,0 +1,5 @@ +@import '../../../styles/variables.scss'; + +.btn { + min-width: $edit-item-button-min-width; +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index b8d3ca7957..4ea47f08e7 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -1,10 +1,12 @@ -import {fadeIn, fadeInOut} from '../../shared/animations/fade'; -import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; -import {ActivatedRoute} from '@angular/router'; -import {RemoteData} from '../../core/data/remote-data'; -import {Item} from '../../core/shared/item.model'; -import {Observable} from 'rxjs'; -import {map} from 'rxjs/operators'; +import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params, Router } from '@angular/router'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getItemPageRoute } from '../item-page-routing.module'; @Component({ selector: 'ds-edit-item-page', @@ -25,11 +27,34 @@ export class EditItemPageComponent implements OnInit { */ itemRD$: Observable>; - constructor(private route: ActivatedRoute) { + /** + * The current page outlet string + */ + currentPage: string; + + /** + * All possible page outlet strings + */ + pages: string[]; + + constructor(private route: ActivatedRoute, private router: Router) { + this.router.events.subscribe(() => { + this.currentPage = this.route.snapshot.firstChild.routeConfig.path; + }); } ngOnInit(): void { + this.pages = this.route.routeConfig.children + .map((child: any) => child.path) + .filter((path: string) => isNotEmpty(path)); // ignore reroutes this.itemRD$ = this.route.data.pipe(map((data) => data.item)); } + /** + * Get the item page url + * @param item The item for which the url is requested + */ + getItemPage(item: Item): string { + return getItemPageRoute(item.id) + } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 0a7b363d6a..0c1de642ce 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -1,17 +1,20 @@ -import {NgModule} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {SharedModule} from '../../shared/shared.module'; -import {EditItemPageRoutingModule} from './edit-item-page.routing.module'; -import {EditItemPageComponent} from './edit-item-page.component'; -import {ItemStatusComponent} from './item-status/item-status.component'; -import {ItemOperationComponent} from './item-operation/item-operation.component'; -import {ModifyItemOverviewComponent} from './modify-item-overview/modify-item-overview.component'; -import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component'; -import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component'; -import {AbstractSimpleItemActionComponent} from './simple-item-action/abstract-simple-item-action.component'; -import {ItemPrivateComponent} from './item-private/item-private.component'; -import {ItemPublicComponent} from './item-public/item-public.component'; -import {ItemDeleteComponent} from './item-delete/item-delete.component'; +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../../shared/shared.module'; +import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; +import { EditItemPageComponent } from './edit-item-page.component'; +import { ItemStatusComponent } from './item-status/item-status.component'; +import { ItemOperationComponent } from './item-operation/item-operation.component'; +import { ModifyItemOverviewComponent } from './modify-item-overview/modify-item-overview.component'; +import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; +import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component'; +import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract-simple-item-action.component'; +import { ItemPrivateComponent } from './item-private/item-private.component'; +import { ItemPublicComponent } from './item-public/item-public.component'; +import { ItemDeleteComponent } from './item-delete/item-delete.component'; +import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; +import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -32,7 +35,10 @@ import {ItemDeleteComponent} from './item-delete/item-delete.component'; ItemPrivateComponent, ItemPublicComponent, ItemDeleteComponent, - ItemStatusComponent + ItemStatusComponent, + ItemMetadataComponent, + ItemBitstreamsComponent, + EditInPlaceFieldComponent ] }) export class EditItemPageModule { diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 8ef6f43e17..223b5f7c8e 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -1,12 +1,15 @@ -import {ItemPageResolver} from '../item-page.resolver'; -import {NgModule} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {EditItemPageComponent} from './edit-item-page.component'; -import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component'; -import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component'; -import {ItemPrivateComponent} from './item-private/item-private.component'; -import {ItemPublicComponent} from './item-public/item-public.component'; -import {ItemDeleteComponent} from './item-delete/item-delete.component'; +import { ItemPageResolver } from '../item-page.resolver'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EditItemPageComponent } from './edit-item-page.component'; +import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; +import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component'; +import { ItemPrivateComponent } from './item-private/item-private.component'; +import { ItemPublicComponent } from './item-public/item-public.component'; +import { ItemDeleteComponent } from './item-delete/item-delete.component'; +import { ItemStatusComponent } from './item-status/item-status.component'; +import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -25,7 +28,40 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; component: EditItemPageComponent, resolve: { item: ItemPageResolver - } + }, + children: [ + { + path: '', + redirectTo: 'status', + }, + { + path: 'status', + component: ItemStatusComponent, + data: { title: 'item.edit.tabs.status.title' } + }, + { + path: 'bitstreams', + component: ItemBitstreamsComponent, + data: { title: 'item.edit.tabs.bitstreams.title' } + }, + { + path: 'metadata', + component: ItemMetadataComponent, + data: { title: 'item.edit.tabs.metadata.title' } + }, + { + path: 'view', + /* TODO - change when view page exists */ + component: ItemBitstreamsComponent, + data: { title: 'item.edit.tabs.view.title' } + }, + { + path: 'curate', + /* TODO - change when curate page exists */ + component: ItemBitstreamsComponent, + data: { title: 'item.edit.tabs.curate.title' } + }, + ] }, { path: ITEM_EDIT_WITHDRAW_PATH, diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html new file mode 100644 index 0000000000..b80e6e0678 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss new file mode 100644 index 0000000000..88eb98509a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables.scss'; \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts new file mode 100644 index 0000000000..71f25cd5cf --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-item-bitstreams', + styleUrls: ['./item-bitstreams.component.scss'], + templateUrl: './item-bitstreams.component.html', +}) +/** + * Component for displaying an item's bitstreams edit page + */ +export class ItemBitstreamsComponent { + /* TODO implement */ +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html new file mode 100644 index 0000000000..e9c5de95ca --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -0,0 +1,70 @@ + + + + +
+
+ {{metadata?.value}} +
+
+ +
+
+ + +
+
+ {{metadata?.language}} +
+
+ +
+
+ + +
+ + + + +
+ \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss new file mode 100644 index 0000000000..14782326f6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss @@ -0,0 +1,14 @@ +@import '../../../../../styles/variables.scss'; +.btn[disabled] { + color: $gray-600; + border-color: $gray-600; + z-index: 0; // prevent border colors jumping on hover +} + +.metadata-field { + width: $edit-item-metadata-field-width; +} + +.language-field { + width: $edit-item-language-field-width; +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts new file mode 100644 index 0000000000..09363b9964 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -0,0 +1,432 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { EditInPlaceFieldComponent } from './edit-in-place-field.component'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { MetadataField } from '../../../../core/metadata/metadatafield.model'; +import { By } from '@angular/platform-browser'; +import { FormsModule } from '@angular/forms'; +import { SharedModule } from '../../../../shared/shared.module'; +import { getTestScheduler } from 'jasmine-marbles'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; +import { TestScheduler } from 'rxjs/testing'; +import { MetadataSchema } from '../../../../core/metadata/metadataschema.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { TranslateModule } from '@ngx-translate/core'; +import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; + +let comp: EditInPlaceFieldComponent; +let fixture: ComponentFixture; +let de: DebugElement; +let el: HTMLElement; +let metadataFieldService; +let objectUpdatesService; +let paginatedMetadataFields; +const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }) +const mdField1 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'contributor', + qualifier: 'author' +}); +const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' }); +const mdField3 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'description', + qualifier: 'abstract' +}); + +const metadatum = Object.assign(new MetadatumViewModel(), { + key: 'dc.description.abstract', + value: 'Example abstract', + language: 'en' +}); + +const url = 'http://test-url.com/test-url'; +const fieldUpdate = { + field: metadatum, + changeType: undefined +}; +let scheduler: TestScheduler; + +describe('EditInPlaceFieldComponent', () => { + + beforeEach(async(() => { + scheduler = getTestScheduler(); + + paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); + + metadataFieldService = jasmine.createSpyObj({ + queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)), + }); + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + saveChangeFieldUpdate: {}, + saveRemoveFieldUpdate: {}, + setEditableFieldUpdate: {}, + setValidFieldUpdate: {}, + removeSingleFieldUpdate: {}, + isEditable: observableOf(false), // should always return something --> its in ngOnInit + isValid: observableOf(true) // should always return something --> its in ngOnInit + } + ); + + TestBed.configureTestingModule({ + imports: [FormsModule, SharedModule, TranslateModule.forRoot()], + declarations: [EditInPlaceFieldComponent], + providers: [ + { provide: RegistryService, useValue: metadataFieldService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditInPlaceFieldComponent); + comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance + de = fixture.debugElement; + el = de.nativeElement; + + comp.url = url; + comp.fieldUpdate = fieldUpdate; + comp.metadata = metadatum; + + fixture.detectChanges(); + }); + + describe('update', () => { + beforeEach(() => { + comp.update(); + }); + + it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum); + }); + }); + + describe('setEditable', () => { + const editable = false; + beforeEach(() => { + comp.setEditable(editable); + }); + + it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => { + expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid, editable); + }); + }); + + describe('editable is true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + fixture.detectChanges(); + }); + it('the div should contain input fields or textareas', () => { + const inputField = de.queryAll(By.css('input')); + const textAreas = de.queryAll(By.css('textarea')); + expect(inputField.length + textAreas.length).toBeGreaterThan(0); + }); + }); + + describe('editable is false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + fixture.detectChanges(); + }); + it('the div should contain no input fields or textareas', () => { + const inputField = de.queryAll(By.css('input')); + const textAreas = de.queryAll(By.css('textarea')); + expect(inputField.length + textAreas.length).toBe(0); + }); + }); + + describe('isValid is true', () => { + beforeEach(() => { + comp.valid = observableOf(true); + fixture.detectChanges(); + }); + it('the div should not contain an error message', () => { + const errorMessages = de.queryAll(By.css('small.text-danger')); + expect(errorMessages.length).toBe(0); + + }); + }); + + describe('isValid is false', () => { + beforeEach(() => { + comp.valid = observableOf(false); + fixture.detectChanges(); + }); + it('the div should contain no input fields or textareas', () => { + const errorMessages = de.queryAll(By.css('small.text-danger')); + expect(errorMessages.length).toBeGreaterThan(0); + + }); + }); + + describe('remove', () => { + beforeEach(() => { + comp.remove(); + }); + + it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum); + }); + }); + + describe('removeChangesFromField', () => { + beforeEach(() => { + comp.removeChangesFromField(); + }); + + it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid); + }); + }); + + describe('findMetadataFieldSuggestions', () => { + const query = 'query string'; + + const metadataFieldSuggestions: InputSuggestion[] = + [ + { displayValue: mdField1.toString().split('.').join('.​'), value: mdField1.toString() }, + { displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() }, + { displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() } + ]; + + beforeEach(() => { + comp.findMetadataFieldSuggestions(query); + + }); + + it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => { + + expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query); + }); + + it('it should set metadataFieldSuggestions to the right value', () => { + const expected = 'a'; + scheduler.expectObservable(comp.metadataFieldSuggestions).toBe(expected, { a: metadataFieldSuggestions }); + }); + }); + + describe('canSetEditable', () => { + describe('when editable is currently true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + }); + + it('canSetEditable should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false }); + }); + }); + + describe('when editable is currently false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + }); + + describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + it('canSetEditable should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.REMOVE; + }); + it('canSetEditable should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false }); + }); + }) + }); + }); + + describe('canSetUneditable', () => { + describe('when editable is currently true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + }); + + it('canSetUneditable should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: true }); + }); + }); + + describe('when editable is currently false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + }); + + it('canSetUneditable should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: false }); + }); + }); + }); + + describe('when canSetEditable emits true', () => { + beforeEach(() => { + comp.editable = observableOf(false); + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(false); + }); + }); + + describe('when canSetEditable emits false', () => { + beforeEach(() => { + comp.editable = observableOf(false); + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(true); + }); + }); + + describe('when canSetUneditable emits true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(false); + }); + }); + + describe('when canSetUneditable emits false', () => { + beforeEach(() => { + comp.editable = observableOf(true); + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(true); + }); + }); + + describe('when canRemove emits true', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(false); + }); + }); + + describe('when canRemove emits false', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(true); + }); + }); + + describe('when canUndo emits true', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should have an enabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(false); + }); + }); + + describe('when canUndo emits false', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should have a disabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(true); + }); + }); + + describe('canRemove', () => { + describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.UPDATE; + }); + it('canRemove should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + it('canRemove should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false }); + }); + }) + }); + + describe('canUndo', () => { + + describe('when editable is currently true', () => { + beforeEach(() => { + comp.editable = observableOf(true); + comp.fieldUpdate.changeType = undefined; + fixture.detectChanges(); + }); + it('canUndo should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); + }); + }); + + describe('when editable is currently false', () => { + describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + + it('canUndo should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently undefined', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = undefined; + }); + + it('canUndo should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false }); + }); + }); + }); + + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts new file mode 100644 index 0000000000..0b9bc62c55 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts @@ -0,0 +1,194 @@ +import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { hasValue, isNotEmpty } from '../../../../shared/empty.util'; +import { RegistryService } from '../../../../core/registry/registry.service'; +import { cloneDeep } from 'lodash'; +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { MetadataField } from '../../../../core/metadata/metadatafield.model'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { NgModel } from '@angular/forms'; +import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[ds-edit-in-place-field]', + styleUrls: ['./edit-in-place-field.component.scss'], + templateUrl: './edit-in-place-field.component.html', +}) +/** + * Component that displays a single metadatum of an item on the edit page + */ +export class EditInPlaceFieldComponent implements OnInit, OnChanges { + /** + * The current field, value and state of the metadatum + */ + @Input() fieldUpdate: FieldUpdate; + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * List of strings with all metadata field keys available + */ + @Input() metadataFields: string[]; + + /** + * The metadatum of this field + */ + metadata: MetadatumViewModel; + + /** + * Emits whether or not this field is currently editable + */ + editable: Observable; + + /** + * Emits whether or not this field is currently valid + */ + valid: Observable; + + /** + * The current suggestions for the metadatafield when editing + */ + metadataFieldSuggestions: BehaviorSubject = new BehaviorSubject([]); + + constructor( + private metadataFieldService: RegistryService, + private objectUpdatesService: ObjectUpdatesService, + ) { + } + + /** + * Sets up an observable that keeps track of the current editable and valid state of this field + */ + ngOnInit(): void { + this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid); + this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid); + } + + /** + * Sends a new change update for this field to the object updates service + */ + update(ngModel?: NgModel) { + this.objectUpdatesService.saveChangeFieldUpdate(this.url, this.metadata); + if (hasValue(ngModel)) { + this.checkValidity(ngModel); + } + } + + /** + * Method to check the validity of a form control + * @param ngModel + */ + private checkValidity(ngModel: NgModel) { + ngModel.control.setValue(ngModel.viewModel); + ngModel.control.updateValueAndValidity(); + this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, ngModel.control.valid); + } + + /** + * Sends a new editable state for this field to the service to change it + * @param editable The new editable state for this field + */ + setEditable(editable: boolean) { + this.objectUpdatesService.setEditableFieldUpdate(this.url, this.metadata.uuid, editable); + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove() { + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.metadata); + } + + /** + * Notifies the object updates service that the updates for the current field can be removed + */ + removeChangesFromField() { + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid); + } + + /** + * Sets the current metadatafield based on the fieldUpdate input field + */ + ngOnChanges(): void { + this.metadata = cloneDeep(this.fieldUpdate.field) as MetadatumViewModel; + } + + /** + * Requests all metadata fields that contain the query string in their key + * Then sets all found metadata fields as metadataFieldSuggestions + * @param query The query to look for + */ + findMetadataFieldSuggestions(query: string): void { + if (isNotEmpty(query)) { + this.metadataFieldService.queryMetadataFields(query).pipe( + // getSucceededRemoteData(), + take(1), + map((data) => data.payload.page) + ).subscribe( + (fields: MetadataField[]) => this.metadataFieldSuggestions.next( + fields.map((field: MetadataField) => { + return { + displayValue: field.toString().split('.').join('.​'), + value: field.toString() + }; + }) + ) + ); + } else { + this.metadataFieldSuggestions.next([]); + } + } + + /** + * Check if a user should be allowed to edit this field + * @return an observable that emits true when the user should be able to edit this field and false when they should not + */ + canSetEditable(): Observable { + return this.editable.pipe( + map((editable: boolean) => { + if (editable) { + return false; + } else { + return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + }) + ); + } + + /** + * Check if a user should be allowed to disabled editing this field + * @return an observable that emits true when the user should be able to disable editing this field and false when they should not + */ + canSetUneditable(): Observable { + return this.editable; + } + + /** + * Check if a user should be allowed to remove this field + * @return an observable that emits true when the user should be able to remove this field and false when they should not + */ + canRemove(): Observable { + return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD); + } + + /** + * Check if a user should be allowed to undo changes to this field + * @return an observable that emits true when the user should be able to undo changes to this field and false when they should not + */ + canUndo(): Observable { + return this.editable.pipe( + map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable) + ); + } + + protected isNotEmpty(value): boolean { + return isNotEmpty(value); + } +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html new file mode 100644 index 0000000000..496429a3ba --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -0,0 +1,64 @@ + diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss new file mode 100644 index 0000000000..f3075702e6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/variables.scss'; + +.button-row { + .btn { + margin-right: 0.5 * $spacer; + + &:last-child { + margin-right: 0; + } + + @media screen and (min-width: map-get($grid-breakpoints, sm)) { + min-width: $edit-item-button-min-width; + } + } + + &.top .btn { + margin-top: $spacer/2; + margin-bottom: $spacer/2; + } + + +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts new file mode 100644 index 0000000000..f2cd74fc2f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts @@ -0,0 +1,278 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { getTestScheduler } from 'jasmine-marbles'; +import { ItemMetadataComponent } from './item-metadata.component'; +import { TestScheduler } from 'rxjs/testing'; +import { SharedModule } from '../../../shared/shared.module'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { By } from '@angular/platform-browser'; +import { + INotification, + Notification +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { Item } from '../../../core/shared/item.model'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { RemoteData } from '../../../core/data/remote-data'; +import { MetadatumViewModel } from '../../../core/shared/metadata.models'; +import { RegistryService } from '../../../core/registry/registry.service'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; +import { MetadataField } from '../../../core/metadata/metadatafield.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; + +let comp: ItemMetadataComponent; +let fixture: ComponentFixture; +let de: DebugElement; +let el: HTMLElement; +let objectUpdatesService; +const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); +const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); +const date = new Date(); +const router = new RouterStub(); +let metadataFieldService; +let paginatedMetadataFields; +let routeStub; + +const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }); +const mdField1 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'contributor', + qualifier: 'author' +}); +const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' }); +const mdField3 = Object.assign(new MetadataField(), { + schema: mdSchema, + element: 'description', + qualifier: 'abstract' +}); + +let itemService; +const notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } +); +const metadatum1 = Object.assign(new MetadatumViewModel(), { + key: 'dc.description.abstract', + value: 'Example abstract', + language: 'en' +}); + +const metadatum2 = Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Title test', + language: 'de' +}); + +const metadatum3 = Object.assign(new MetadatumViewModel(), { + key: 'dc.contributor.author', + value: 'Shakespeare, William', +}); + +const url = 'http://test-url.com/test-url'; + +router.url = url; + +const fieldUpdate1 = { + field: metadatum1, + changeType: undefined +}; + +const fieldUpdate2 = { + field: metadatum2, + changeType: FieldChangeType.REMOVE +}; + +const fieldUpdate3 = { + field: metadatum3, + changeType: undefined +}; + +let scheduler: TestScheduler; +let item; +describe('ItemMetadataComponent', () => { + beforeEach(async(() => { + item = Object.assign(new Item(), { + metadata: { + [metadatum1.key]: [metadatum1], + [metadatum2.key]: [metadatum2], + [metadatum3.key]: [metadatum3] + } + }, + { + lastModified: date + } + ) + ; + itemService = jasmine.createSpyObj('itemService', { + update: observableOf(new RemoteData(false, false, true, undefined, item)), + commitUpdates: {} + }); + routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, item) }) + } + }; + paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); + + metadataFieldService = jasmine.createSpyObj({ + getAllMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)) + }); + scheduler = getTestScheduler(); + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [metadatum1.uuid]: fieldUpdate1, + [metadatum2.uuid]: fieldUpdate2, + [metadatum3.uuid]: fieldUpdate3 + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) + } + ); + + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + declarations: [ItemMetadataComponent], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, + { provide: RegistryService, useValue: metadataFieldService }, + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemMetadataComponent); + comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance + de = fixture.debugElement; + el = de.nativeElement; + comp.url = url; + fixture.detectChanges(); + }); + + describe('add', () => { + const md = new MetadatumViewModel(); + beforeEach(() => { + comp.add(md); + }); + + it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md); + }); + }); + + describe('discard', () => { + beforeEach(() => { + comp.discard(); + }); + + it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => { + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); + }); + }); + + describe('reinstate', () => { + beforeEach(() => { + comp.reinstate(); + }); + + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => { + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); + }); + }); + + describe('submit', () => { + beforeEach(() => { + comp.submit(); + }); + + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList); + expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) })); + expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList); + }); + }); + + describe('hasChanges', () => { + describe('when the objectUpdatesService\'s hasUpdated method returns true', () => { + beforeEach(() => { + objectUpdatesService.hasUpdates.and.returnValue(observableOf(true)); + }); + + it('should return an observable that emits true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: true }); + }); + }); + + describe('when the objectUpdatesService\'s hasUpdated method returns false', () => { + beforeEach(() => { + objectUpdatesService.hasUpdates.and.returnValue(observableOf(false)); + }); + + it('should return an observable that emits false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: false }); + }); + }); + }); + + describe('changeType is UPDATE', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.UPDATE; + fixture.detectChanges(); + }); + it('the div should have class table-warning', () => { + const element = de.queryAll(By.css('tr'))[1].nativeElement; + expect(element.classList).toContain('table-warning'); + }); + }); + + describe('changeType is ADD', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.ADD; + fixture.detectChanges(); + }); + it('the div should have class table-success', () => { + const element = de.queryAll(By.css('tr'))[1].nativeElement; + expect(element.classList).toContain('table-success'); + }); + }); + + describe('changeType is REMOVE', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class table-danger', () => { + const element = de.queryAll(By.css('tr'))[1].nativeElement; + expect(element.classList).toContain('table-danger'); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts new file mode 100644 index 0000000000..6b3e05c818 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -0,0 +1,233 @@ +import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { cloneDeep } from 'lodash'; +import { Observable } from 'rxjs'; +import { + FieldUpdate, + FieldUpdates, + Identifiable +} from '../../../core/data/object-updates/object-updates.reducer'; +import { first, map, switchMap, take, tap } from 'rxjs/operators'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { TranslateService } from '@ngx-translate/core'; +import { RegistryService } from '../../../core/registry/registry.service'; +import { MetadataField } from '../../../core/metadata/metadatafield.model'; +import { MetadatumViewModel } from '../../../core/shared/metadata.models'; +import { Metadata } from '../../../core/shared/metadata.utils'; + +@Component({ + selector: 'ds-item-metadata', + styleUrls: ['./item-metadata.component.scss'], + templateUrl: './item-metadata.component.html', +}) +/** + * Component for displaying an item's metadata edit page + */ +export class ItemMetadataComponent implements OnInit { + + /** + * The item to display the edit page for + */ + item: Item; + /** + * The current values and updates for all this item's metadata fields + */ + updates$: Observable; + /** + * The current url of this page + */ + url: string; + /** + * The time span for being able to undo discarding changes + */ + private discardTimeOut: number; + /** + * Prefix for this component's notification translate keys + */ + private notificationsPrefix = 'item.edit.metadata.notifications.'; + + /** + * Observable with a list of strings with all existing metadata field keys + */ + metadataFields$: Observable; + + constructor( + private itemService: ItemDataService, + private objectUpdatesService: ObjectUpdatesService, + private router: Router, + private notificationsService: NotificationsService, + private translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private route: ActivatedRoute, + private metadataFieldService: RegistryService, + ) { + + } + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + this.metadataFields$ = this.findMetadataFields(); + this.route.parent.data.pipe(map((data) => data.item)) + .pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.item = item; + }); + + this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); + } + this.hasChanges().pipe(first()).subscribe((hasChanges) => { + if (!hasChanges) { + this.initializeOriginalFields(); + } else { + this.checkLastModified(); + } + }); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + } + + /** + * Sends a new add update for a field to the object updates service + * @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum + */ + add(metadata: MetadatumViewModel = new MetadatumViewModel()) { + this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); + + } + + /** + * Request the object updates service to discard all current changes to this item + * Shows a notification to remind the user that they can undo this + */ + discard() { + const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); + this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); + } + + /** + * Request the object updates service to undo discarding all changes to this item + */ + reinstate() { + this.objectUpdatesService.reinstateFieldUpdates(this.url); + } + + /** + * Sends all initial values of this item to the object updates service + */ + private initializeOriginalFields() { + this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); + } + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + + /** + * Requests all current metadata for this item and requests the item service to update the item + * Makes sure the new version of the item is rendered on the page + */ + submit() { + this.isValid().pipe(first()).subscribe((isValid) => { + if (isValid) { + const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable; + metadata$.pipe( + first(), + switchMap((metadata: MetadatumViewModel[]) => { + const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) }); + return this.itemService.update(updatedItem); + }), + tap(() => this.itemService.commitUpdates()), + getSucceededRemoteData() + ).subscribe( + (rd: RemoteData) => { + this.item = rd.payload; + this.initializeOriginalFields(); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + } + ) + } else { + this.notificationsService.error(this.getNotificationTitle('invalid'), this.getNotificationContent('invalid')); + } + }); + } + + /** + * Checks whether or not there are currently updates for this item + */ + hasChanges(): Observable { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Checks whether or not the item is currently reinstatable + */ + isReinstatable(): Observable { + return this.objectUpdatesService.isReinstatable(this.url); + } + + /** + * Checks if the current item is still in sync with the version in the store + * If it's not, a notification is shown and the changes are removed + */ + private checkLastModified() { + const currentVersion = this.item.lastModified; + this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( + (updateVersion: Date) => { + if (updateVersion.getDate() !== currentVersion.getDate()) { + this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); + this.initializeOriginalFields(); + } + } + ); + } + + /** + * Check if the current page is entirely valid + */ + private isValid() { + return this.objectUpdatesService.isValidPage(this.url); + } + + /** + * Get translated notification title + * @param key + */ + private getNotificationTitle(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + private getNotificationContent(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } + + /** + * Method to request all metadata fields and convert them to a list of strings + */ + findMetadataFields(): Observable { + return this.metadataFieldService.getAllMetadataFields().pipe( + getSucceededRemoteData(), + take(1), + map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); + } +} diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html index 0f7d9a5607..e60fa0490d 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -12,7 +12,7 @@ {{'item.edit.tabs.status.labels.itemPage' | translate}}:
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts index f5aec6e287..00ea9b9f62 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts @@ -6,11 +6,12 @@ import { CommonModule } from '@angular/common'; import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub'; import { HostWindowService } from '../../../shared/host-window.service'; import { RouterTestingModule } from '@angular/router/testing'; -import { Router } from '@angular/router'; -import { RouterStub } from '../../../shared/testing/router-stub'; +import { ActivatedRoute } from '@angular/router'; import { Item } from '../../../core/shared/item.model'; import { By } from '@angular/platform-browser'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; describe('ItemStatusComponent', () => { let comp: ItemStatusComponent; @@ -22,17 +23,20 @@ describe('ItemStatusComponent', () => { lastModified: '2018' }); - const itemPageUrl = `fake-url/${mockItem.id}`; - const routerStub = Object.assign(new RouterStub(), { - url: `${itemPageUrl}/edit` - }); + const itemPageUrl = `items/${mockItem.id}`; + + const routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) }) + } + }; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [ItemStatusComponent], providers: [ - { provide: Router, useValue: routerStub }, + { provide: ActivatedRoute, useValue: routeStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -41,7 +45,6 @@ describe('ItemStatusComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ItemStatusComponent); comp = fixture.componentInstance; - comp.item = mockItem; fixture.detectChanges(); }); @@ -65,4 +68,5 @@ describe('ItemStatusComponent', () => { expect(statusItemPage.textContent).toContain(itemPageUrl); }); -}); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 2b2c7a2ed4..c7e3a023d1 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -1,8 +1,12 @@ -import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; -import {fadeIn, fadeInOut} from '../../../shared/animations/fade'; -import {Item} from '../../../core/shared/item.model'; -import {Router} from '@angular/router'; -import {ItemOperation} from '../item-operation/itemOperation.model'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; +import { Item } from '../../../core/shared/item.model'; +import { ActivatedRoute } from '@angular/router'; +import { ItemOperation } from '../item-operation/itemOperation.model'; +import { first, map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module'; @Component({ selector: 'ds-item-status', @@ -21,7 +25,7 @@ export class ItemStatusComponent implements OnInit { /** * The item to display the status for */ - @Input() item: Item; + itemRD$: Observable>; /** * The data to show in the status @@ -37,59 +41,62 @@ export class ItemStatusComponent implements OnInit { * key: id value: url to action's component */ operations: ItemOperation[]; + /** * The keys of the actions (to loop over) */ actionsKeys; - constructor(private router: Router) { + constructor(private route: ActivatedRoute) { } ngOnInit(): void { - this.statusData = Object.assign({ - id: this.item.id, - handle: this.item.handle, - lastModified: this.item.lastModified + this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)); + this.itemRD$.pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.statusData = Object.assign({ + id: item.id, + handle: item.handle, + lastModified: item.lastModified + }); + this.statusDataKeys = Object.keys(this.statusData); + /* + The key is used to build messages + i18n example: 'item.edit.tabs.status.buttons..label' + The value is supposed to be a href for the button + */ + this.operations = []; + if (item.isWithdrawn) { + this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); + } else { + this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw')); + } + if (item.isDiscoverable) { + this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); + } else { + this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); + } + this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); }); - this.statusDataKeys = Object.keys(this.statusData); - /* - The key is used to build messages - i18n example: 'item.edit.tabs.status.buttons..label' - The value is supposed to be a href for the button - */ - this.operations = []; - if (this.item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); - } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); - } - if (this.item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); - } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); - } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); } /** * Get the url to the simple item page * @returns {string} url */ - getItemPage(): string { - return this.router.url.substr(0, this.router.url.lastIndexOf('/')); + getItemPage(item: Item): string { + return getItemPageRoute(item.id) } /** * Get the current url without query params * @returns {string} url */ - getCurrentUrl(): string { - if (this.router.url.indexOf('?') > -1) { - return this.router.url.substr(0, this.router.url.indexOf('?')); - } else { - return this.router.url; - } + getCurrentUrl(item: Item): string { + return getItemEditPath(item.id); } } 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 282f8687e1..974bc8d37f 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 {MetadataMap} from '../../../core/shared/metadata.interfaces'; +import {MetadataMap} from '../../../core/shared/metadata.models'; @Component({ selector: 'ds-modify-item-overview', 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 09d855e951..67684d44af 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,7 +1,7 @@ import { Component, Input } from '@angular/core'; import { MetadataValuesComponent } from '../metadata-values/metadata-values.component'; -import { MetadataValue } from '../../../core/shared/metadata.interfaces'; +import { MetadataValue } from '../../../core/shared/metadata.models'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link. 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 708bdb49c7..abcd90848d 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 { MetadataValue } from '../../../core/shared/metadata.interfaces'; +import { MetadataValue } from '../../../core/shared/metadata.models'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. 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 fcb724b564..6e19a50864 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 { MetadataMap } from '../../core/shared/metadata.interfaces'; +import { MetadataMap } from '../../core/shared/metadata.models'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 8c1f317bb7..ec562842aa 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -4,9 +4,9 @@ import { RouterModule } from '@angular/router'; import { ItemPageComponent } from './simple/item-page.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { ItemPageResolver } from './item-page.resolver'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getItemModulePath } from '../app-routing.module'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import {URLCombiner} from '../core/url-combiner/url-combiner'; -import {getItemModulePath} from '../app-routing.module'; export function getItemPageRoute(itemId: string) { return new URLCombiner(getItemModulePath(), itemId).toString(); @@ -39,7 +39,7 @@ const ITEM_EDIT_PATH = ':id/edit'; path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', canActivate: [AuthenticatedGuard] - } + }, ]) ], providers: [ diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts index f6ab366d95..c7335bcde4 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, autoserializeAs } from 'cerialize'; -import { MetadataMap } from '../core/shared/metadata.interfaces'; +import { MetadataMap } from '../core/shared/metadata.models'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; /** diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index f48cad15cc..659f49413c 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -21,6 +21,7 @@ import { SearchService } from '../../../search-service/search.service'; import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; @Component({ @@ -60,7 +61,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * Emits the result values for this filter found by the current filter query */ - filterSearchResults: Observable = observableOf([]); + filterSearchResults: Observable = observableOf([]); /** * Emits the active values for this filter @@ -267,7 +268,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { map( (rd: RemoteData>) => { return rd.payload.page.map((facet) => { - return { displayValue: this.getDisplayValue(facet, data), value: facet.value } + return { + displayValue: this.getDisplayValue(facet, data), + value: facet.value + } }) } )) diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts index b2e5eafdec..ff865610c6 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 { MetadataMap } from '../core/shared/metadata.interfaces'; +import { MetadataMap } from '../core/shared/metadata.models'; import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; /** diff --git a/src/app/+search-page/search-service/facet-value.model.ts b/src/app/+search-page/search-service/facet-value.model.ts index d5102ec68d..d2cc521356 100644 --- a/src/app/+search-page/search-service/facet-value.model.ts +++ b/src/app/+search-page/search-service/facet-value.model.ts @@ -1,4 +1,3 @@ - import { autoserialize, autoserializeAs } from 'cerialize'; /** diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts index b6df1fac34..e37475d94c 100644 --- a/src/app/core/auth/auth-object-factory.ts +++ b/src/app/core/auth/auth-object-factory.ts @@ -3,10 +3,10 @@ import { GenericConstructor } from '../shared/generic-constructor'; import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { EPerson } from '../eperson/models/eperson.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; export class AuthObjectFactory { - public static getConstructor(type): GenericConstructor { + public static getConstructor(type): GenericConstructor> { switch (type) { case AuthType.EPerson: { return NormalizedEPerson diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 3390997a1e..fdb372f643 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -152,7 +152,7 @@ export class AuthService { // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... // Review when https://jira.duraspace.org/browse/DS-4006 is fixed // See https://github.com/DSpace/dspace-angular/issues/292 - const person$ = this.rdbService.buildSingle(status.eperson.toString()); + const person$ = this.rdbService.buildSingle(status.eperson.toString()); return person$.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts index b8dd2aa23e..a13a996604 100644 --- a/src/app/core/auth/models/normalized-auth-status.model.ts +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -7,7 +7,7 @@ import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; @mapsTo(AuthStatus) @inheritSerialization(NormalizedObject) -export class NormalizedAuthStatus extends NormalizedObject { +export class NormalizedAuthStatus extends NormalizedObject { @autoserialize id: string; diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 868d444c26..b61b11a4f2 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -10,7 +10,6 @@ import { AuthService } from './auth.service'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { CheckAuthenticationTokenAction } from './auth.actions'; import { EPerson } from '../eperson/models/eperson.model'; -import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; /** * The auth service. @@ -40,8 +39,10 @@ export class ServerAuthService extends AuthService { if (status.authenticated) { // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... - const person$ = this.rdbService.buildSingle(status.eperson.toString()); - return person$.pipe(map((eperson) => eperson.payload)); + const person$ = this.rdbService.buildSingle(status.eperson.toString()); + return person$.pipe( + map((eperson) => eperson.payload) + ); } else { throw(new Error('Not authenticated')); } diff --git a/src/app/core/browse/browse-entry-search-options.model.ts b/src/app/core/browse/browse-entry-search-options.model.ts index a4911a33f1..417bf7ce75 100644 --- a/src/app/core/browse/browse-entry-search-options.model.ts +++ b/src/app/core/browse/browse-entry-search-options.model.ts @@ -12,6 +12,7 @@ export class BrowseEntrySearchOptions { constructor(public metadataDefinition: string, public pagination?: PaginationComponentOptions, public sort?: SortOptions, + public startsWith?: string, public scope?: string) { } } diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 5a493592a8..725b371c14 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -281,4 +281,38 @@ describe('BrowseService', () => { }); }); }); + + describe('getFirstItemFor', () => { + beforeEach(() => { + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + service = initTestService(); + spyOn(service, 'getBrowseDefinitions').and + .returnValue(hot('--a-', { a: { + payload: browseDefinitions + }})); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + }); + + describe('when getFirstItemFor is called with a valid browse definition id', () => { + const expectedURL = browseDefinitions[1]._links.items + '?page=0&size=1'; + + it('should configure a new BrowseItemsRequest', () => { + const expected = new BrowseItemsRequest(requestService.generateRequestId(), expectedURL); + + scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getFirstItemFor(browseDefinitions[1].id); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + }); + + }); + }); + }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index ef4fdaa5ff..bf368e37ce 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,17 +1,14 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, map, startWith, take } from 'rxjs/operators'; import { - ensureArrayHasValue, + ensureArrayHasValue, hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortOptions } from '../cache/models/sort-options.model'; -import { GenericSuccessResponse } from '../cache/response.models'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; import { @@ -26,17 +23,19 @@ import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, - filterSuccessfulResponses, - getBrowseDefinitionLinks, - getRemoteDataPayload, getRequestFromRequestHref + filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence, + getRemoteDataPayload, + getRequestFromRequestHref } from '../shared/operators'; 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'; +import { GenericSuccessResponse } from '../cache/response.models'; +import { RequestEntry } from '../data/request.reducer'; /** - * Service that performs all actions that have to do with browse. + * The service handling all browse requests */ @Injectable() export class BrowseService { @@ -62,6 +61,9 @@ export class BrowseService { ) { } + /** + * Get all BrowseDefinitions + */ getBrowseDefinitions(): Observable> { const request$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), @@ -84,8 +86,12 @@ export class BrowseService { return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } + /** + * Get all BrowseEntries filtered or modified by BrowseEntrySearchOptions + * @param options + */ getBrowseEntriesFor(options: BrowseEntrySearchOptions): Observable>> { - const request$ = this.getBrowseDefinitions().pipe( + return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.entries), @@ -93,7 +99,7 @@ export class BrowseService { map((href: string) => { // TODO nearly identical to PaginatedSearchOptions => refactor const args = []; - if (isNotEmpty(options.sort)) { + if (isNotEmpty(options.scope)) { args.push(`scope=${options.scope}`); } if (isNotEmpty(options.sort)) { @@ -103,49 +109,33 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(args)) { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; }), - map((endpointURL: string) => new BrowseEntriesRequest(this.requestService.generateRequestId(), endpointURL)), - configureRequest(this.requestService) + getBrowseEntriesFor(this.requestService, this.rdb) ); - - const href$ = request$.pipe(map((request: RestRequest) => request.href)); - - const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - - const payload$ = requestEntry$.pipe( - filterSuccessfulResponses(), - map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), - map((list: PaginatedList) => Object.assign(list, { - page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page - })), - distinctUntilChanged() - ); - - return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } /** * Get all items linked to a certain metadata value - * @param {string} definitionID definition ID to define the metadata-field (e.g. author) * @param {string} filterValue metadata value to filter by (e.g. author's name) - * @param options Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } + * @param options Options to narrow down your search * @returns {Observable>>} */ getBrowseItemsFor(filterValue: string, options: BrowseEntrySearchOptions): Observable>> { - const request$ = this.getBrowseDefinitions().pipe( + return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.items), hasValueOperator(), map((href: string) => { const args = []; - if (isNotEmpty(options.sort)) { + if (isNotEmpty(options.scope)) { args.push(`scope=${options.scope}`); } if (isNotEmpty(options.sort)) { @@ -155,6 +145,9 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(filterValue)) { args.push(`filterValue=${filterValue}`); } @@ -163,26 +156,83 @@ export class BrowseService { } return href; }), - map((endpointURL: string) => new BrowseItemsRequest(this.requestService.generateRequestId(), endpointURL)), - configureRequest(this.requestService) + getBrowseItemsFor(this.requestService, this.rdb) ); - - const href$ = request$.pipe(map((request: RestRequest) => request.href)); - - const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - - const payload$ = requestEntry$.pipe( - filterSuccessfulResponses(), - map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), - map((list: PaginatedList) => Object.assign(list, { - page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page - })), - distinctUntilChanged() - ); - - return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } + /** + * Get the first item for a metadata definition in an optional scope + * @param definition + * @param scope + */ + getFirstItemFor(definition: string, scope?: string): Observable> { + return this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(definition), + hasValueOperator(), + map((_links: any) => _links.items), + hasValueOperator(), + map((href: string) => { + const args = []; + if (hasValue(scope)) { + args.push(`scope=${scope}`); + } + args.push('page=0'); + args.push('size=1'); + if (isNotEmpty(args)) { + href = new URLCombiner(href, `?${args.join('&')}`).toString(); + } + return href; + }), + getBrowseItemsFor(this.requestService, this.rdb), + getFirstOccurrence() + ); + } + + /** + * Get the previous page of items using the paginated list's prev link + * @param items + */ + getPrevBrowseItems(items: RemoteData>): Observable>> { + return observableOf(items.payload.prev).pipe( + getBrowseItemsFor(this.requestService, this.rdb) + ); + } + + /** + * Get the next page of items using the paginated list's next link + * @param items + */ + getNextBrowseItems(items: RemoteData>): Observable>> { + return observableOf(items.payload.next).pipe( + getBrowseItemsFor(this.requestService, this.rdb) + ); + } + + /** + * Get the previous page of browse-entries using the paginated list's prev link + * @param entries + */ + getPrevBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.prev).pipe( + getBrowseEntriesFor(this.requestService, this.rdb) + ); + } + + /** + * Get the next page of browse-entries using the paginated list's next link + * @param entries + */ + getNextBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.next).pipe( + getBrowseEntriesFor(this.requestService, this.rdb) + ); + } + + /** + * Get the browse URL by providing a metadatum key and linkPath + * @param metadatumKey + * @param linkPath + */ getBrowseURLFor(metadataKey: string, linkPath: string): Observable { const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey); return this.getBrowseDefinitions().pipe( @@ -206,3 +256,79 @@ export class BrowseService { } } + +/** + * Operator for turning a href into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ +export const getBrowseEntriesFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new BrowseEntriesRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedBrowseEntries(requestService, rdb) + ); + +/** + * Operator for turning a href into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ +export const getBrowseItemsFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new BrowseItemsRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedBrowseItems(requestService, rdb) + ); + +/** + * Operator for turning a RestRequest into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ +export const toRDPaginatedBrowseItems = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; + +/** + * Operator for turning a RestRequest into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ +export const toRDPaginatedBrowseEntries = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; diff --git a/src/app/core/cache/builders/normalized-object-build.service.ts b/src/app/core/cache/builders/normalized-object-build.service.ts index 9d97ccda75..79665fec3d 100644 --- a/src/app/core/cache/builders/normalized-object-build.service.ts +++ b/src/app/core/cache/builders/normalized-object-build.service.ts @@ -35,7 +35,7 @@ export class NormalizedObjectBuildService { * * @param {TDomain} domainModel a domain model */ - normalize(domainModel: TDomain): TNormalized { + normalize(domainModel: T): NormalizedObject { const normalizedConstructor = NormalizedObjectFactory.getConstructor(domainModel.type); const relationships = getRelationships(normalizedConstructor) || []; diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 48490e5ecb..e30b8c9955 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,6 +1,8 @@ -import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs'; import { Injectable } from '@angular/core'; + +import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs'; import { distinctUntilChanged, flatMap, map, startWith, switchMap } from 'rxjs/operators'; + import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; @@ -8,7 +10,6 @@ import { RemoteDataError } from '../../data/remote-data-error'; import { GetRequest } from '../../data/request.models'; import { RequestEntry } from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; - import { NormalizedObject } from '../models/normalized-object.model'; import { ObjectCacheService } from '../object-cache.service'; import { DSOSuccessResponse, ErrorResponse } from '../response.models'; @@ -20,6 +21,7 @@ import { getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators'; +import { CacheableObject } from '../object-cache.reducer'; @Injectable() export class RemoteDataBuildService { @@ -27,7 +29,7 @@ export class RemoteDataBuildService { protected requestService: RequestService) { } - buildSingle(href$: string | Observable): Observable> { + buildSingle(href$: string | Observable): Observable> { if (typeof href$ === 'string') { href$ = observableOf(href$); } @@ -44,13 +46,13 @@ export class RemoteDataBuildService { const payload$ = observableCombineLatest( href$.pipe( - switchMap((href: string) => this.objectCache.getBySelfLink(href)), + switchMap((href: string) => this.objectCache.getBySelfLink(href)), startWith(undefined)), requestEntry$.pipe( getResourceLinksFromResponse(), switchMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { - return this.objectCache.getBySelfLink(resourceSelfLinks[0]); + return this.objectCache.getBySelfLink(resourceSelfLinks[0]); } else { return observableOf(undefined); } @@ -67,8 +69,8 @@ export class RemoteDataBuildService { } }), hasValueOperator(), - map((normalized: TNormalized) => { - return this.build(normalized); + map((normalized: NormalizedObject) => { + return this.build(normalized); }), startWith(undefined), distinctUntilChanged() @@ -79,8 +81,8 @@ export class RemoteDataBuildService { toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { return observableCombineLatest(requestEntry$, payload$).pipe( map(([reqEntry, payload]) => { - const requestPending = hasValue(reqEntry) && hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; - const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; + const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; + const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; let isSuccessful: boolean; let error: RemoteDataError; if (hasValue(reqEntry) && hasValue(reqEntry.response)) { @@ -105,7 +107,7 @@ export class RemoteDataBuildService { ); } - buildList(href$: string | Observable): Observable>> { + buildList(href$: string | Observable): Observable>> { if (typeof href$ === 'string') { href$ = observableOf(href$); } @@ -115,9 +117,9 @@ export class RemoteDataBuildService { getResourceLinksFromResponse(), flatMap((resourceUUIDs: string[]) => { return this.objectCache.getList(resourceUUIDs).pipe( - map((normList: TNormalized[]) => { - return normList.map((normalized: TNormalized) => { - return this.build(normalized); + map((normList: Array>) => { + return normList.map((normalized: NormalizedObject) => { + return this.build(normalized); }); })); }), @@ -147,7 +149,7 @@ export class RemoteDataBuildService { return this.toRemoteDataObservable(requestEntry$, payload$); } - build(normalized: TNormalized): TDomain { + build(normalized: NormalizedObject): T { const links: any = {}; const relationships = getRelationships(normalized.constructor) || []; diff --git a/src/app/core/cache/models/normalized-bitstream-format.model.ts b/src/app/core/cache/models/normalized-bitstream-format.model.ts index 5d11c97107..994792d535 100644 --- a/src/app/core/cache/models/normalized-bitstream-format.model.ts +++ b/src/app/core/cache/models/normalized-bitstream-format.model.ts @@ -11,7 +11,7 @@ import { SupportLevel } from './support-level.model'; */ @mapsTo(BitstreamFormat) @inheritSerialization(NormalizedObject) -export class NormalizedBitstreamFormat extends NormalizedObject { +export class NormalizedBitstreamFormat extends NormalizedObject { /** * Short description of this Bitstream Format diff --git a/src/app/core/cache/models/normalized-bitstream.model.ts b/src/app/core/cache/models/normalized-bitstream.model.ts index 63f84add41..64a17aae84 100644 --- a/src/app/core/cache/models/normalized-bitstream.model.ts +++ b/src/app/core/cache/models/normalized-bitstream.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Bitstream) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBitstream extends NormalizedDSpaceObject { +export class NormalizedBitstream extends NormalizedDSpaceObject { /** * The size of this bitstream in bytes diff --git a/src/app/core/cache/models/normalized-bundle.model.ts b/src/app/core/cache/models/normalized-bundle.model.ts index 5535ab57e5..342b13629f 100644 --- a/src/app/core/cache/models/normalized-bundle.model.ts +++ b/src/app/core/cache/models/normalized-bundle.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Bundle) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBundle extends NormalizedDSpaceObject { +export class NormalizedBundle extends NormalizedDSpaceObject { /** * The primary bitstream of this Bundle */ diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts index 5cbb2d327c..ddfcc29a2c 100644 --- a/src/app/core/cache/models/normalized-collection.model.ts +++ b/src/app/core/cache/models/normalized-collection.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { Collection } from '../../shared/collection.model'; @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Collection) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedCollection extends NormalizedDSpaceObject { +export class NormalizedCollection extends NormalizedDSpaceObject { /** * A string representing the unique handle of this Collection @@ -35,28 +35,28 @@ export class NormalizedCollection extends NormalizedDSpaceObject { /** * The Bitstream that represents the logo of this Collection */ - @autoserialize + @deserialize @relationship(ResourceType.Bitstream, false) logo: string; /** * An array of Communities that are direct parents of this Collection */ - @autoserialize + @deserialize @relationship(ResourceType.Community, true) parents: string[]; /** * The Community that owns this Collection */ - @autoserialize + @deserialize @relationship(ResourceType.Community, false) owner: string; /** * List of Items that are part of (not necessarily owned by) this Collection */ - @autoserialize + @deserialize @relationship(ResourceType.Item, true) items: string[]; diff --git a/src/app/core/cache/models/normalized-community.model.ts b/src/app/core/cache/models/normalized-community.model.ts index e915d2f50a..f561089949 100644 --- a/src/app/core/cache/models/normalized-community.model.ts +++ b/src/app/core/cache/models/normalized-community.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Community) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedCommunity extends NormalizedDSpaceObject { +export class NormalizedCommunity extends NormalizedDSpaceObject { /** * A string representing the unique handle of this Community 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 87bd4b4369..e12faa4a77 100644 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -1,6 +1,6 @@ -import { autoserialize, autoserializeAs, deserialize, serialize } from 'cerialize'; +import { autoserializeAs, deserializeAs } from 'cerialize'; import { DSpaceObject } from '../../shared/dspace-object.model'; -import { MetadataMap } from '../../shared/metadata.interfaces'; +import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; import { ResourceType } from '../../shared/resource-type'; import { mapsTo } from '../builders/build-decorators'; import { NormalizedObject } from './normalized-object.model'; @@ -9,7 +9,7 @@ import { NormalizedObject } from './normalized-object.model'; * An model class for a DSpaceObject. */ @mapsTo(DSpaceObject) -export class NormalizedDSpaceObject extends NormalizedObject { +export class NormalizedDSpaceObject extends NormalizedObject { /** * The link to the rest endpoint where this object can be found @@ -17,7 +17,7 @@ export class NormalizedDSpaceObject extends NormalizedObject { * Repeated here to make the serialization work, * inheritSerialization doesn't seem to work for more than one level */ - @deserialize + @deserializeAs(String) self: string; /** @@ -31,35 +31,32 @@ export class NormalizedDSpaceObject extends NormalizedObject { /** * The universally unique identifier of this DSpaceObject - * - * Repeated here to make the serialization work, - * inheritSerialization doesn't seem to work for more than one level */ - @autoserialize + @autoserializeAs(String) uuid: string; /** * A string representing the kind of DSpaceObject, e.g. community, item, … */ - @autoserialize + @autoserializeAs(String) type: ResourceType; /** * All metadata of this DSpaceObject */ - @autoserialize + @autoserializeAs(MetadataMapSerializer) metadata: MetadataMap; /** * An array of DSpaceObjects that are direct parents of this DSpaceObject */ - @deserialize + @deserializeAs(String) parents: string[]; /** * The DSpaceObject that owns this DSpaceObject */ - @deserialize + @deserializeAs(String) owner: string; /** @@ -68,7 +65,7 @@ export class NormalizedDSpaceObject extends NormalizedObject { * Repeated here to make the serialization work, * inheritSerialization doesn't seem to work for more than one level */ - @deserialize + @deserializeAs(Object) _links: { [name: string]: string } diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts index 7d518bd048..9e8c034e81 100644 --- a/src/app/core/cache/models/normalized-item.model.ts +++ b/src/app/core/cache/models/normalized-item.model.ts @@ -1,4 +1,4 @@ -import { inheritSerialization, autoserialize, autoserializeAs } from 'cerialize'; +import { inheritSerialization, deserialize, autoserialize, autoserializeAs } from 'cerialize'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { Item } from '../../shared/item.model'; @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Item) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedItem extends NormalizedDSpaceObject { +export class NormalizedItem extends NormalizedDSpaceObject { /** * A string representing the unique handle of this Item @@ -21,7 +21,7 @@ export class NormalizedItem extends NormalizedDSpaceObject { /** * The Date of the last modification of this Item */ - @autoserialize + @deserialize lastModified: Date; /** @@ -45,21 +45,21 @@ export class NormalizedItem extends NormalizedDSpaceObject { /** * An array of Collections that are direct parents of this Item */ - @autoserialize + @deserialize @relationship(ResourceType.Collection, true) parents: string[]; /** * The Collection that owns this Item */ - @autoserialize + @deserialize @relationship(ResourceType.Collection, false) owningCollection: string; /** * List of Bitstreams that are owned by this Item */ - @autoserialize + @deserialize @relationship(ResourceType.Bitstream, true) bitstreams: string[]; diff --git a/src/app/core/cache/models/normalized-license.model.ts b/src/app/core/cache/models/normalized-license.model.ts index 1457394c9d..02bd1808c8 100644 --- a/src/app/core/cache/models/normalized-license.model.ts +++ b/src/app/core/cache/models/normalized-license.model.ts @@ -8,7 +8,7 @@ import { License } from '../../shared/license.model'; */ @mapsTo(License) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedLicense extends NormalizedDSpaceObject { +export class NormalizedLicense extends NormalizedDSpaceObject { /** * A boolean representing if this License is custom or not diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index 3e0c805be8..7052432487 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -15,13 +15,14 @@ import { NormalizedWorkflowItem } from '../../submission/models/normalized-workf import { NormalizedClaimedTask } from '../../tasks/models/normalized-claimed-task-object.model'; import { NormalizedPoolTask } from '../../tasks/models/normalized-pool-task-object.model'; import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model'; -import { SubmissionDefinitionsModel } from '../../config/models/config-submission-definitions.model'; -import { SubmissionFormsModel } from '../../config/models/config-submission-forms.model'; -import { SubmissionSectionModel } from '../../config/models/config-submission-section.model'; import { NormalizedMetadataSchema } from '../../metadata/normalized-metadata-schema.model'; +import { CacheableObject } from '../object-cache.reducer'; +import { NormalizedSubmissionDefinitionsModel } from '../../config/models/normalized-config-submission-definitions.model'; +import { NormalizedSubmissionFormsModel } from '../../config/models/normalized-config-submission-forms.model'; +import { NormalizedSubmissionSectionModel } from '../../config/models/normalized-config-submission-section.model'; export class NormalizedObjectFactory { - public static getConstructor(type: ResourceType): GenericConstructor { + public static getConstructor(type: ResourceType): GenericConstructor> { switch (type) { case ResourceType.Bitstream: { return NormalizedBitstream @@ -47,9 +48,6 @@ export class NormalizedObjectFactory { case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } - case ResourceType.Workspaceitem: { - return NormalizedWorkspaceItem - } case ResourceType.EPerson: { return NormalizedEPerson } @@ -62,6 +60,9 @@ export class NormalizedObjectFactory { case ResourceType.MetadataField: { return NormalizedGroup } + case ResourceType.Workspaceitem: { + return NormalizedWorkspaceItem + } case ResourceType.Workflowitem: { return NormalizedWorkflowItem } @@ -71,20 +72,17 @@ export class NormalizedObjectFactory { case ResourceType.PoolTask: { return NormalizedPoolTask } - case ResourceType.BitstreamFormat: { - return NormalizedBitstreamFormat - } case ResourceType.SubmissionDefinition: case ResourceType.SubmissionDefinitions: { - return SubmissionDefinitionsModel + return NormalizedSubmissionDefinitionsModel } case ResourceType.SubmissionForm: case ResourceType.SubmissionForms: { - return SubmissionFormsModel + return NormalizedSubmissionFormsModel } case ResourceType.SubmissionSection: case ResourceType.SubmissionSections: { - return SubmissionSectionModel + return NormalizedSubmissionSectionModel } default: { return undefined; diff --git a/src/app/core/cache/models/normalized-object.model.ts b/src/app/core/cache/models/normalized-object.model.ts index e98081d68a..6ac8985d64 100644 --- a/src/app/core/cache/models/normalized-object.model.ts +++ b/src/app/core/cache/models/normalized-object.model.ts @@ -4,7 +4,7 @@ import { ResourceType } from '../../shared/resource-type'; /** * An abstract model class for a NormalizedObject. */ -export abstract class NormalizedObject implements CacheableObject { +export abstract class NormalizedObject implements CacheableObject { /** * The link to the rest endpoint where this object can be found @@ -13,11 +13,8 @@ export abstract class NormalizedObject implements CacheableObject { self: string; /** - * The universally unique identifier of this Object + * A string representing the kind of DSpaceObject, e.g. community, item, … */ - @autoserialize - uuid: string; - @autoserialize type: ResourceType; diff --git a/src/app/core/cache/models/normalized-resource-policy.model.ts b/src/app/core/cache/models/normalized-resource-policy.model.ts index d0ba3d3f68..9438c1da0a 100644 --- a/src/app/core/cache/models/normalized-resource-policy.model.ts +++ b/src/app/core/cache/models/normalized-resource-policy.model.ts @@ -1,10 +1,9 @@ import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { ResourcePolicy } from '../../shared/resource-policy.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; +import { mapsTo } from '../builders/build-decorators'; import { NormalizedObject } from './normalized-object.model'; import { IDToUUIDSerializer } from '../id-to-uuid-serializer'; -import { ResourceType } from '../../shared/resource-type'; import { ActionType } from './action-type.model'; /** @@ -12,7 +11,7 @@ import { ActionType } from './action-type.model'; */ @mapsTo(ResourcePolicy) @inheritSerialization(NormalizedObject) -export class NormalizedResourcePolicy extends NormalizedObject { +export class NormalizedResourcePolicy extends NormalizedObject { /** * The action that is allowed by this Resource Policy diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index af30646f53..d4d52b404f 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -78,7 +78,7 @@ export class ObjectCacheService { * @return Observable * An observable of the requested object */ - getByUUID(uuid: string): Observable { + getByUUID(uuid: string): Observable> { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), mergeMap((selfLink: string) => this.getBySelfLink(selfLink) @@ -86,7 +86,7 @@ export class ObjectCacheService { ) } - getBySelfLink(selfLink: string): Observable { + getBySelfLink(selfLink: string): Observable> { return this.getEntry(selfLink).pipe( map((entry: ObjectCacheEntry) => { if (isNotEmpty(entry.patches)) { @@ -99,8 +99,8 @@ export class ObjectCacheService { } ), map((entry: ObjectCacheEntry) => { - const type: GenericConstructor = NormalizedObjectFactory.getConstructor(entry.data.type); - return Object.assign(new type(), entry.data) as T + const type: GenericConstructor> = NormalizedObjectFactory.getConstructor(entry.data.type); + return Object.assign(new type(), entry.data) as NormalizedObject }) ); } @@ -145,7 +145,7 @@ export class ObjectCacheService { * The type of the objects to get * @return Observable> */ - getList(selfLinks: string[]): Observable { + getList(selfLinks: string[]): Observable>> { return observableCombineLatest( selfLinks.map((selfLink: string) => this.getBySelfLink(selfLink)) ); diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 8034a90413..316e02e4be 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -11,8 +11,9 @@ import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstream import { AuthStatus } from '../auth/models/auth-status.model'; import { MetadataSchema } from '../metadata/metadataschema.model'; import { MetadataField } from '../metadata/metadatafield.model'; -import { NormalizedObject } from './models/normalized-object.model'; import { PaginatedList } from '../data/paginated-list'; +import { SubmissionObject } from '../submission/models/submission-object.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -233,7 +234,7 @@ export class PostPatchSuccessResponse extends RestResponse { export class SubmissionSuccessResponse extends RestResponse { constructor( - public dataDefinition: Array, + public dataDefinition: Array, public statusCode: number, public statusText: string, public pageInfo?: PageInfo @@ -244,7 +245,7 @@ export class SubmissionSuccessResponse extends RestResponse { export class EpersonSuccessResponse extends RestResponse { constructor( - public epersonDefinition: NormalizedObject[], + public epersonDefinition: DSpaceObject[], public statusCode: number, public statusText: string, public pageInfo?: PageInfo diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index 3e3715d186..c86a0d5654 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -26,7 +26,6 @@ export interface ServerSyncBufferState { buffer: ServerSyncBufferEntry[]; } -// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) const initialState: ServerSyncBufferState = { buffer: [] }; /** diff --git a/src/app/core/config/config-response-parsing.service.spec.ts b/src/app/core/config/config-response-parsing.service.spec.ts index 32c29fdda1..7c69f1bdb3 100644 --- a/src/app/core/config/config-response-parsing.service.spec.ts +++ b/src/app/core/config/config-response-parsing.service.spec.ts @@ -6,10 +6,10 @@ import { ConfigRequest } from '../data/request.models'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; -import { SubmissionDefinitionsModel } from './models/config-submission-definitions.model'; import { PaginatedList } from '../data/paginated-list'; import { PageInfo } from '../shared/page-info.model'; -import { SubmissionSectionModel } from './models/config-submission-section.model'; +import { NormalizedSubmissionDefinitionsModel } from './models/normalized-config-submission-definitions.model'; +import { NormalizedSubmissionSectionModel } from './models/normalized-config-submission-section.model'; describe('ConfigResponseParsingService', () => { let service: ConfigResponseParsingService; @@ -173,7 +173,7 @@ describe('ConfigResponseParsingService', () => { self: 'https://rest.api/config/submissiondefinitions/traditional/sections' }); const definitions = - Object.assign(new SubmissionDefinitionsModel(), { + Object.assign(new NormalizedSubmissionDefinitionsModel(), { isDefault: true, name: 'traditional', type: 'submissiondefinition', @@ -183,7 +183,7 @@ describe('ConfigResponseParsingService', () => { }, self: 'https://rest.api/config/submissiondefinitions/traditional', sections: new PaginatedList(pageinfo, [ - Object.assign(new SubmissionSectionModel(), { + Object.assign(new NormalizedSubmissionSectionModel(), { header: 'submit.progressbar.describe.stepone', mandatory: true, sectionType: 'submission-form', @@ -198,7 +198,7 @@ describe('ConfigResponseParsingService', () => { }, self: 'https://rest.api/config/submissionsections/traditionalpageone', }), - Object.assign(new SubmissionSectionModel(), { + Object.assign(new NormalizedSubmissionSectionModel(), { header: 'submit.progressbar.describe.steptwo', mandatory: true, sectionType: 'submission-form', @@ -213,7 +213,7 @@ describe('ConfigResponseParsingService', () => { }, self: 'https://rest.api/config/submissionsections/traditionalpagetwo', }), - Object.assign(new SubmissionSectionModel(), { + Object.assign(new NormalizedSubmissionSectionModel(), { header: 'submit.progressbar.upload', mandatory: false, sectionType: 'upload', @@ -228,7 +228,7 @@ describe('ConfigResponseParsingService', () => { }, self: 'https://rest.api/config/submissionsections/upload', }), - Object.assign(new SubmissionSectionModel(), { + Object.assign(new NormalizedSubmissionSectionModel(), { header: 'submit.progressbar.license', mandatory: true, sectionType: 'license', diff --git a/src/app/core/config/models/config-object-factory.ts b/src/app/core/config/models/config-object-factory.ts index 08a10201b9..5dbba7a11f 100644 --- a/src/app/core/config/models/config-object-factory.ts +++ b/src/app/core/config/models/config-object-factory.ts @@ -1,30 +1,29 @@ import { GenericConstructor } from '../../shared/generic-constructor'; - -import { SubmissionSectionModel } from './config-submission-section.model'; -import { SubmissionFormsModel } from './config-submission-forms.model'; -import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; import { ConfigType } from './config-type'; import { ConfigObject } from './config.model'; -import { SubmissionUploadsModel } from './config-submission-uploads.model'; +import { NormalizedSubmissionDefinitionsModel } from './normalized-config-submission-definitions.model'; +import { NormalizedSubmissionFormsModel } from './normalized-config-submission-forms.model'; +import { NormalizedSubmissionSectionModel } from './normalized-config-submission-section.model'; +import { NormalizedSubmissionUploadsModel } from './normalized-config-submission-uploads.model'; export class ConfigObjectFactory { public static getConstructor(type): GenericConstructor { switch (type) { case ConfigType.SubmissionDefinition: case ConfigType.SubmissionDefinitions: { - return SubmissionDefinitionsModel + return NormalizedSubmissionDefinitionsModel } case ConfigType.SubmissionForm: case ConfigType.SubmissionForms: { - return SubmissionFormsModel + return NormalizedSubmissionFormsModel } case ConfigType.SubmissionSection: case ConfigType.SubmissionSections: { - return SubmissionSectionModel + return NormalizedSubmissionSectionModel } case ConfigType.SubmissionUpload: case ConfigType.SubmissionUploads: { - return SubmissionUploadsModel + return NormalizedSubmissionUploadsModel } default: { return undefined; diff --git a/src/app/core/config/models/config-submission-definitions.model.ts b/src/app/core/config/models/config-submission-definitions.model.ts index 0247f13944..8bbbc90056 100644 --- a/src/app/core/config/models/config-submission-definitions.model.ts +++ b/src/app/core/config/models/config-submission-definitions.model.ts @@ -1,15 +1,17 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; import { SubmissionSectionModel } from './config-submission-section.model'; import { PaginatedList } from '../../data/paginated-list'; -@inheritSerialization(ConfigObject) export class SubmissionDefinitionsModel extends ConfigObject { - @autoserialize + /** + * A boolean representing if this submission definition is the default or not + */ isDefault: boolean; - @autoserializeAs(SubmissionSectionModel) + /** + * A list of SubmissionSectionModel that are present in this submission definition + */ sections: PaginatedList; } diff --git a/src/app/core/config/models/config-submission-forms.model.ts b/src/app/core/config/models/config-submission-forms.model.ts index 98d3bf9ce7..ee0962f0e9 100644 --- a/src/app/core/config/models/config-submission-forms.model.ts +++ b/src/app/core/config/models/config-submission-forms.model.ts @@ -1,14 +1,20 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; +/** + * An interface that define a form row and its properties. + */ export interface FormRowModel { fields: FormFieldModel[]; } -@inheritSerialization(ConfigObject) +/** + * A model class for a NormalizedObject. + */ export class SubmissionFormsModel extends ConfigObject { - @autoserialize + /** + * An array of [FormRowModel] that are present in this form + */ rows: FormRowModel[]; } diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts index 69b5af2d1a..377a8869e1 100644 --- a/src/app/core/config/models/config-submission-section.model.ts +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -1,23 +1,34 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; import { SectionsType } from '../../../submission/sections/sections-type'; -@inheritSerialization(ConfigObject) +/** + * An interface that define section visibility and its properties. + */ +export interface SubmissionSectionVisibility { + main: any, + other: any +} + export class SubmissionSectionModel extends ConfigObject { - @autoserialize + /** + * The header for this section + */ header: string; - @autoserialize + /** + * A boolean representing if this submission section is the mandatory or not + */ mandatory: boolean; - @autoserialize + /** + * A string representing the kind of section object + */ sectionType: SectionsType; - @autoserialize - visibility: { - main: any, - other: any - } + /** + * The [SubmissionSectionVisibility] object for this section + */ + visibility: SubmissionSectionVisibility } diff --git a/src/app/core/config/models/config-submission-uploads.model.ts b/src/app/core/config/models/config-submission-uploads.model.ts index 0e5b15d388..8bb9ba7f1e 100644 --- a/src/app/core/config/models/config-submission-uploads.model.ts +++ b/src/app/core/config/models/config-submission-uploads.model.ts @@ -1,30 +1,21 @@ -import {autoserialize, autoserializeAs, inheritSerialization} from 'cerialize'; import { ConfigObject } from './config.model'; import { AccessConditionOption } from './config-access-condition-option.model'; -import {SubmissionFormsModel} from './config-submission-forms.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; -/** - * Normalized model class for the configuration describing the submission upload section - */ -@inheritSerialization(ConfigObject) export class SubmissionUploadsModel extends ConfigObject { /** * A list of available bitstream access conditions */ - @autoserialize accessConditionOptions: AccessConditionOption[]; /** * An object representing the configuration describing the bistream metadata form */ - @autoserializeAs(SubmissionFormsModel) metadata: SubmissionFormsModel; - @autoserialize required: boolean; - @autoserialize maxSize: number; } diff --git a/src/app/core/config/models/config-type.ts b/src/app/core/config/models/config-type.ts index a240035eb9..91371f10f5 100644 --- a/src/app/core/config/models/config-type.ts +++ b/src/app/core/config/models/config-type.ts @@ -1,8 +1,3 @@ -/** - * TODO replace with actual string enum after upgrade to TypeScript 2.4: - * https://github.com/Microsoft/TypeScript/pull/15486 - */ - export enum ConfigType { SubmissionDefinitions = 'submissiondefinitions', SubmissionDefinition = 'submissiondefinition', diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index 05b98873c0..81f20a0b3c 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -1,10 +1,27 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { NormalizedObject } from '../../cache/models/normalized-object.model'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../../shared/resource-type'; -@inheritSerialization(NormalizedObject) -export abstract class ConfigObject extends NormalizedObject { +export abstract class ConfigObject implements CacheableObject { - @autoserialize + /** + * The name for this configuration + */ public name: string; + /** + * A string representing the kind of config object + */ + public type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + public _links: { + [name: string]: string + }; + + /** + * The link to the rest endpoint where this config object can be found + */ + self: string; } diff --git a/src/app/core/config/models/normalized-config-submission-definitions.model.ts b/src/app/core/config/models/normalized-config-submission-definitions.model.ts new file mode 100644 index 0000000000..3887c566c1 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-definitions.model.ts @@ -0,0 +1,25 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { SubmissionSectionModel } from './config-submission-section.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; + +/** + * Normalized class for the configuration describing the submission + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionDefinitionsModel extends NormalizedConfigObject { + + /** + * A boolean representing if this submission definition is the default or not + */ + @autoserialize + isDefault: boolean; + + /** + * A list of SubmissionSectionModel that are present in this submission definition + */ + @autoserializeAs(SubmissionSectionModel) + sections: PaginatedList; + +} diff --git a/src/app/core/config/models/normalized-config-submission-forms.model.ts b/src/app/core/config/models/normalized-config-submission-forms.model.ts new file mode 100644 index 0000000000..a957e8c7fa --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-forms.model.ts @@ -0,0 +1,16 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { FormRowModel, SubmissionFormsModel } from './config-submission-forms.model'; + +/** + * Normalized class for the configuration describing the submission form + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionFormsModel extends NormalizedConfigObject { + + /** + * An array of [FormRowModel] that are present in this form + */ + @autoserialize + rows: FormRowModel[]; +} diff --git a/src/app/core/config/models/normalized-config-submission-section.model.ts b/src/app/core/config/models/normalized-config-submission-section.model.ts new file mode 100644 index 0000000000..c876acf607 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-section.model.ts @@ -0,0 +1,37 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { SectionsType } from '../../../submission/sections/sections-type'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; +import { SubmissionSectionVisibility } from './config-submission-section.model'; + +/** + * Normalized class for the configuration describing the submission section + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionSectionModel extends NormalizedConfigObject { + + /** + * The header for this section + */ + @autoserialize + header: string; + + /** + * A boolean representing if this submission section is the mandatory or not + */ + @autoserialize + mandatory: boolean; + + /** + * A string representing the kind of section object + */ + @autoserialize + sectionType: SectionsType; + + /** + * The [SubmissionSectionVisibility] object for this section + */ + @autoserialize + visibility: SubmissionSectionVisibility + +} diff --git a/src/app/core/config/models/normalized-config-submission-uploads.model.ts b/src/app/core/config/models/normalized-config-submission-uploads.model.ts new file mode 100644 index 0000000000..e49171d6a7 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-uploads.model.ts @@ -0,0 +1,31 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { AccessConditionOption } from './config-access-condition-option.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionUploadsModel } from './config-submission-uploads.model'; + +/** + * Normalized class for the configuration describing the submission upload section + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionUploadsModel extends NormalizedConfigObject { + + /** + * A list of available bitstream access conditions + */ + @autoserialize + accessConditionOptions: AccessConditionOption[]; + + /** + * An object representing the configuration describing the bistream metadata form + */ + @autoserializeAs(SubmissionFormsModel) + metadata: SubmissionFormsModel; + + @autoserialize + required: boolean; + + @autoserialize + maxSize: number; + +} diff --git a/src/app/core/config/models/normalized-config.model.ts b/src/app/core/config/models/normalized-config.model.ts new file mode 100644 index 0000000000..0b75158588 --- /dev/null +++ b/src/app/core/config/models/normalized-config.model.ts @@ -0,0 +1,38 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { NormalizedObject } from '../../cache/models/normalized-object.model'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * Normalized abstract class for a configuration object + */ +@inheritSerialization(NormalizedObject) +export abstract class NormalizedConfigObject implements CacheableObject { + + /** + * The name for this configuration + */ + @autoserialize + public name: string; + + /** + * A string representing the kind of config object + */ + @autoserialize + public type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @autoserialize + public _links: { + [name: string]: string + }; + + /** + * The link to the rest endpoint where this config object can be found + */ + @autoserialize + self: string; + +} diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 7863ba8a69..bb25c49a7a 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -5,6 +5,7 @@ import { RequestEffects } from './data/request.effects'; import { AuthEffects } from './auth/auth.effects'; import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; +import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; export const coreEffects = [ RequestEffects, @@ -12,5 +13,6 @@ export const coreEffects = [ UUIDIndexEffects, AuthEffects, JsonPatchOperationsEffects, - ServerSyncBufferEffects + ServerSyncBufferEffects, + ObjectUpdatesEffects ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index ce8e3397d2..3d399d6284 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -77,6 +77,8 @@ import { MenuService } from '../shared/menu/menu.service'; import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; +import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; +import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; import { RoleService } from './roles/role.service'; import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; @@ -163,8 +165,10 @@ const PROVIDERS = [ FileService, DSpaceObjectDataService, DSOChangeAnalyzer, + DefaultChangeAnalyzer, CSSVariableService, MenuService, + ObjectUpdatesService, MyDSpaceGuard, RoleService, MessageResponseParsingService, @@ -182,20 +186,15 @@ const PROVIDERS = [ { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; -const DIRECTIVES = [ -]; - @NgModule({ imports: [ ...IMPORTS ], declarations: [ - ...DECLARATIONS, - ...DIRECTIVES + ...DECLARATIONS ], exports: [ - ...EXPORTS, - ...DIRECTIVES + ...EXPORTS ], providers: [ ...PROVIDERS diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c4bcdb20ab..ebfe578a6d 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,4 +1,7 @@ -import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; +import { + ActionReducerMap, + createFeatureSelector, +} from '@ngrx/store'; import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; import { indexReducer, IndexState } from './index/index.reducer'; @@ -6,10 +9,15 @@ import { requestReducer, RequestState } from './data/request.reducer'; import { authReducer, AuthState } from './auth/auth.reducer'; import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; +import { + objectUpdatesReducer, + ObjectUpdatesState +} from './data/object-updates/object-updates.reducer'; export interface CoreState { 'cache/object': ObjectCacheState, 'cache/syncbuffer': ServerSyncBufferState, + 'cache/object-updates': ObjectUpdatesState 'data/request': RequestState, 'index': IndexState, 'auth': AuthState, @@ -19,6 +27,7 @@ export interface CoreState { export const coreReducers: ActionReducerMap = { 'cache/object': objectCacheReducer, 'cache/syncbuffer': serverSyncBufferReducer, + 'cache/object-updates': objectUpdatesReducer, 'data/request': requestReducer, 'index': indexReducer, 'auth': authReducer, diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts index 1963e1510a..fb950f6c68 100644 --- a/src/app/core/data/browse-items-response-parsing-service.ts +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -1,20 +1,17 @@ import { Inject, Injectable } from '@angular/core'; + import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { - ErrorResponse, - GenericSuccessResponse, - RestResponse -} from '../cache/response.models'; +import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; /** * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[]) @@ -42,9 +39,11 @@ export class BrowseItemsResponseParsingService extends BaseResponseParsingServic 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(DSpaceObject); + const serializer = new DSpaceRESTv2Serializer(NormalizedDSpaceObject); const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); return new GenericSuccessResponse(items, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else if (hasValue(data.payload) && hasValue(data.payload.page)) { + return new GenericSuccessResponse([], data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( diff --git a/src/app/core/data/change-analyzer.ts b/src/app/core/data/change-analyzer.ts index caf9e38c7c..6b5a69259b 100644 --- a/src/app/core/data/change-analyzer.ts +++ b/src/app/core/data/change-analyzer.ts @@ -1,11 +1,12 @@ import { NormalizedObject } from '../cache/models/normalized-object.model'; import { Operation } from 'fast-json-patch/lib/core'; +import { CacheableObject } from '../cache/object-cache.reducer'; /** * An interface to determine what differs between two * NormalizedObjects */ -export interface ChangeAnalyzer { +export interface ChangeAnalyzer { /** * Compare two objects and return their differences as a @@ -16,5 +17,5 @@ export interface ChangeAnalyzer { * @param {NormalizedObject} object2 * The second object to compare */ - diff(object1: TNormalized, object2: TNormalized): Operation[]; + diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[]; } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index d02e42ce79..1e7f9ec074 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -22,7 +22,7 @@ import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; @Injectable() -export class CollectionDataService extends ComColDataService { +export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; protected forceBypassCache = false; @@ -36,7 +36,7 @@ export class CollectionDataService extends ComColDataService ) { super(); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 52210164c7..cf7b6185ea 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -18,14 +18,16 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { Item } from '../shared/item.model'; +import { Community } from '../shared/community.model'; const LINK_NAME = 'test'; /* tslint:disable:max-classes-per-file */ -class NormalizedTestObject extends NormalizedObject { +class NormalizedTestObject extends NormalizedObject { } -class TestService extends ComColDataService { +class TestService extends ComColDataService { protected forceBypassCache = false; constructor( @@ -39,7 +41,7 @@ class TestService extends ComColDataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, + protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { super(); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 8a1ea51bb3..693b8af58b 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,28 +1,17 @@ -import { - distinctUntilChanged, - filter, - first, - map, - mergeMap, - share, - take, - tap -} from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, mergeMap, share, take, tap } from 'rxjs/operators'; import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CommunityDataService } from './community-data.service'; import { DataService } from './data.service'; import { FindAllOptions, FindByIDRequest } from './request.models'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestEntry } from './request.reducer'; import { getResponseFromEntry } from '../shared/operators'; import { CacheableObject } from '../cache/object-cache.reducer'; -export abstract class ComColDataService extends DataService { +export abstract class ComColDataService extends DataService { protected abstract cds: CommunityDataService; protected abstract objectCache: ObjectCacheService; protected abstract halService: HALEndpointService; diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 89895aea11..75ef58b06b 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -21,7 +21,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @Injectable() -export class CommunityDataService extends ComColDataService { +export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; protected topLinkPath = 'communities/search/top'; protected cds = this; @@ -36,7 +36,7 @@ export class CommunityDataService extends ComColDataService ) { super(); } @@ -55,6 +55,6 @@ export class CommunityDataService extends ComColDataService(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } } diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 3b2c6a975a..910506bc29 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -16,15 +16,17 @@ import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { compare } from 'fast-json-patch'; +import { Item } from '../shared/item.model'; const endpoint = 'https://rest.api/core'; // tslint:disable:max-classes-per-file -class NormalizedTestObject extends NormalizedObject { +class NormalizedTestObject extends NormalizedObject { } -class TestService extends DataService { +class TestService extends DataService { protected forceBypassCache = false; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 945c881bba..984495078b 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,14 +1,7 @@ +import { HttpClient } from '@angular/common/http'; + import { Observable } from 'rxjs'; -import { - distinctUntilChanged, - filter, - find, - first, - map, - mergeMap, - switchMap, - take -} from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; @@ -34,7 +27,6 @@ import { Operation } from 'fast-json-patch'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { configureRequest, getResponseFromEntry } from '../shared/operators'; import { ErrorResponse, RestResponse } from '../cache/response.models'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; @@ -44,8 +36,9 @@ import { CacheableObject } from '../cache/object-cache.reducer'; import { RequestEntry } from './request.reducer'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { ChangeAnalyzer } from './change-analyzer'; +import { RestRequestMethod } from './rest-request-method'; -export abstract class DataService { +export abstract class DataService { protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; protected abstract dataBuildService: NormalizedObjectBuildService; @@ -56,7 +49,7 @@ export abstract class DataService; + protected abstract comparator: ChangeAnalyzer; public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable @@ -106,7 +99,7 @@ export abstract class DataService>> { + findAll(options: FindAllOptions = {}): Observable>> { const hrefObs = this.getFindAllHref(options); hrefObs.pipe( @@ -116,7 +109,7 @@ export abstract class DataService(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } /** @@ -128,7 +121,7 @@ export abstract class DataService> { + findById(id: string): Observable> { const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, id))); @@ -139,12 +132,12 @@ export abstract class DataService(hrefObs); + return this.rdbService.buildSingle(hrefObs); } - findByHref(href: string, options?: HttpOptions): Observable> { + findByHref(href: string, options?: HttpOptions): Observable> { this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href, null, options), this.forceBypassCache); - return this.rdbService.buildSingle(href); + return this.rdbService.buildSingle(href); } protected getSearchEndpoint(searchMethod: string): Observable { @@ -153,7 +146,7 @@ export abstract class DataService `${href}/${searchMethod}`)); } - protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable>> { + protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable>> { const hrefObs = this.getSearchByHref(searchMethod, options); @@ -164,7 +157,7 @@ export abstract class DataService(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } /** @@ -181,11 +174,10 @@ export abstract class DataService> { + update(object: T): Observable> { const oldVersion$ = this.objectCache.getBySelfLink(object.self); - return oldVersion$.pipe(first(), mergeMap((oldVersion: TNormalized) => { - const newVersion = this.dataBuildService.normalize(object); - const operations = this.comparator.diff(oldVersion, newVersion); + return oldVersion$.pipe(take(1), mergeMap((oldVersion: T) => { + const operations = this.comparator.diff(oldVersion, object); if (isNotEmpty(operations)) { this.objectCache.addPatch(object.self, operations); } @@ -204,7 +196,7 @@ export abstract class DataService> { + create(dso: T, parentUUID: string): Observable> { const requestId = this.requestService.generateRequestId(); const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), @@ -212,7 +204,7 @@ export abstract class DataService parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint) ); - const normalizedObject: TNormalized = this.dataBuildService.normalize(dso); + const normalizedObject: NormalizedObject = this.dataBuildService.normalize(dso); const serializedDso = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(dso.type)).serialize(normalizedObject); const request$ = endpoint$.pipe( @@ -253,7 +245,7 @@ export abstract class DataService { + delete(dso: T): Observable { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -273,4 +265,12 @@ export abstract class DataService implements ChangeAnalyzer { + + /** + * Compare the metadata of two CacheableObject and return the differences as + * a JsonPatch Operation Array + * + * @param {NormalizedObject} object1 + * The first object to compare + * @param {NormalizedObject} object2 + * The second object to compare + */ + diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[] { + return compare(object1, object2); + } +} diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index a47359e5c0..dd3487d3d0 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -3,13 +3,14 @@ import { compare } from 'fast-json-patch'; import { ChangeAnalyzer } from './change-analyzer'; import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; import { Injectable } from '@angular/core'; +import { DSpaceObject } from '../shared/dspace-object.model'; /** * A class to determine what differs between two * DSpaceObjects */ @Injectable() -export class DSOChangeAnalyzer implements ChangeAnalyzer { +export class DSOChangeAnalyzer implements ChangeAnalyzer { /** * Compare the metadata of two DSpaceObjects and return the differences as @@ -20,7 +21,7 @@ export class DSOChangeAnalyzer implements ChangeAnalyzer * @param {NormalizedDSpaceObject} object2 * The second object to compare */ - diff(object1: NormalizedDSpaceObject, object2: NormalizedDSpaceObject): Operation[] { + diff(object1: T | NormalizedDSpaceObject, object2: T | NormalizedDSpaceObject): Operation[] { return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); } } diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 54933ac823..eb95cdae8a 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -13,6 +13,7 @@ import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; @Injectable() export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -33,7 +34,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem if (hasValue(data.payload) && hasValue(data.payload.page) && data.payload.page.totalElements === 0) { processRequestDTO = { page: [] }; } else { - processRequestDTO = this.process(data.payload, request.uuid); + processRequestDTO = this.process, ResourceType>(data.payload, request.uuid); } let objectList = processRequestDTO; diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 02f64ae9be..4f0653f416 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -17,7 +16,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; /* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { +class DataServiceImpl extends DataService { protected linkPath = 'dso'; protected forceBypassCache = false; @@ -30,7 +29,7 @@ class DataServiceImpl extends DataService protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { + protected comparator: DSOChangeAnalyzer) { super(); } @@ -56,7 +55,7 @@ export class DSpaceObjectDataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { + protected comparator: DSOChangeAnalyzer) { this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator); } diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index c98037624b..f6adbb23c2 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,11 +1,10 @@ -import {distinctUntilChanged, map, filter} from 'rxjs/operators'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { isNotEmpty } from '../../shared/empty.util'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedItem } from '../cache/models/normalized-item.model'; import { CoreState } from '../core.reducers'; import { Item } from '../shared/item.model'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -13,16 +12,17 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DeleteRequest, FindAllOptions, PatchRequest, RestRequest } from './request.models'; +import { FindAllOptions, PatchRequest, RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { configureRequest, getResponseFromEntry } from '../shared/operators'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.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 { configureRequest, getRequestFromRequestHref } from '../shared/operators'; +import { RequestEntry } from './request.reducer'; @Injectable() -export class ItemDataService extends DataService { +export class ItemDataService extends DataService { protected linkPath = 'items'; protected forceBypassCache = false; @@ -36,7 +36,7 @@ export class ItemDataService extends DataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { + protected comparator: DSOChangeAnalyzer) { super(); } @@ -92,7 +92,9 @@ export class ItemDataService extends DataService { new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - getResponseFromEntry() + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) ); } @@ -111,7 +113,9 @@ export class ItemDataService extends DataService { new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - getResponseFromEntry() + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) ); } } diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 919bc11913..1d2bf3b221 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -10,17 +10,16 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadataschema.model'; -import { NormalizedMetadataSchema } from '../metadata/normalized-metadata-schema.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { ChangeAnalyzer } from './change-analyzer'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +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 { +export class MetadataSchemaDataService extends DataService { protected linkPath = 'metadataschemas'; protected forceBypassCache = false; @@ -30,10 +29,10 @@ export class MetadataSchemaDataService extends DataService, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, + protected comparator: DefaultChangeAnalyzer, protected dataBuildService: NormalizedObjectBuildService, - protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: ChangeAnalyzer) { + protected notificationsService: NotificationsService) { super(); } diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts new file mode 100644 index 0000000000..6cd74b2626 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -0,0 +1,245 @@ +import { type } from '../../../shared/ngrx/type'; +import { Action } from '@ngrx/store'; +import { Identifiable } from './object-updates.reducer'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +/** + * The list of ObjectUpdatesAction type definitions + */ +export const ObjectUpdatesActionTypes = { + INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), + SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), + SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), + ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), + DISCARD: type('dspace/core/cache/object-updates/DISCARD'), + REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), + REMOVE: type('dspace/core/cache/object-updates/REMOVE'), + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store + */ +export enum FieldChangeType { + UPDATE = 0, + ADD = 1, + REMOVE = 2 +} + +/** + * An ngrx action to initialize a new page's fields in the ObjectUpdates state + */ +export class InitializeFieldsAction implements Action { + type = ObjectUpdatesActionTypes.INITIALIZE_FIELDS; + payload: { + url: string, + fields: Identifiable[], + lastModified: Date + }; + + /** + * Create a new InitializeFieldsAction + * + * @param url + * the unique url of the page for which the fields are being initialized + * @param fields The identifiable fields of which the updates are kept track of + * @param lastModified The last modified date of the object that belongs to the page + */ + constructor( + url: string, + fields: Identifiable[], + lastModified: Date + ) { + this.payload = { url, fields, lastModified }; + } +} + +/** + * An ngrx action to add a new field update in the ObjectUpdates state for a certain page url + */ +export class AddFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.ADD_FIELD; + payload: { + url: string, + field: Identifiable, + changeType: FieldChangeType, + }; + + /** + * Create a new AddFieldUpdateAction + * + * @param url + * the unique url of the page for which a field update is added + * @param field The identifiable field of which a new update is added + * @param changeType The update's change type + */ + constructor( + url: string, + field: Identifiable, + changeType: FieldChangeType) { + this.payload = { url, field, changeType }; + } +} + +/** + * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url + */ +export class SetEditableFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.SET_EDITABLE_FIELD; + payload: { + url: string, + uuid: string, + editable: boolean, + }; + + /** + * Create a new SetEditableFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param editable The new editable value for the field + */ + constructor( + url: string, + fieldUUID: string, + editable: boolean) { + this.payload = { url, uuid: fieldUUID, editable }; + } +} + +/** + * An ngrx action to set the isValid state of an existing field in the ObjectUpdates state for a certain page url + */ +export class SetValidFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.SET_VALID_FIELD; + payload: { + url: string, + uuid: string, + isValid: boolean, + }; + + /** + * Create a new SetValidFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param isValid The new isValid value for the field + */ + constructor( + url: string, + fieldUUID: string, + isValid: boolean) { + this.payload = { url, uuid: fieldUUID, isValid }; + } +} + +/** + * An ngrx action to discard all existing updates in the ObjectUpdates state for a certain page url + */ +export class DiscardObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.DISCARD; + payload: { + url: string, + notification: INotification + }; + + /** + * Create a new DiscardObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be discarded + * @param notification The notification that is raised when changes are discarded + */ + constructor( + url: string, + notification: INotification + ) { + this.payload = { url, notification }; + } +} + +/** + * An ngrx action to reinstate all previously discarded updates in the ObjectUpdates state for a certain page url + */ +export class ReinstateObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REINSTATE; + payload: { + url: string + }; + + /** + * Create a new ReinstateObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be reinstated + */ + constructor( + url: string + ) { + this.payload = { url }; + } +} + +/** + * An ngrx action to remove all previously discarded updates in the ObjectUpdates state for a certain page url + */ +export class RemoveObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE; + payload: { + url: string + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be removed + */ + constructor( + url: string + ) { + this.payload = { url }; + } +} + +/** + * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid + */ +export class RemoveFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE_FIELD; + payload: { + url: string, + uuid: string + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which a field's change should be removed + * @param uuid The UUID of the field for which the change should be removed + */ + constructor( + url: string, + uuid: string + ) { + this.payload = { url, uuid }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all ObjectUpdatesActions + */ +export type ObjectUpdatesAction + = AddFieldUpdateAction + | InitializeFieldsAction + | DiscardObjectUpdatesAction + | ReinstateObjectUpdatesAction + | RemoveObjectUpdatesAction + | RemoveFieldUpdateAction; diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts new file mode 100644 index 0000000000..79b1b2df72 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -0,0 +1,122 @@ +import { async, TestBed } from '@angular/core/testing'; +import { Observable, Subject } from 'rxjs'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { cold, hot } from 'jasmine-marbles'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectUpdatesEffects } from './object-updates.effects'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + RemoveObjectUpdatesAction +} from './object-updates.actions'; +import { + INotification, + Notification +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { filter } from 'rxjs/operators'; +import { hasValue } from '../../../shared/empty.util'; + +describe('ObjectUpdatesEffects', () => { + let updatesEffects: ObjectUpdatesEffects; + let actions: Observable; + let testURL = 'www.dspace.org/dspace7'; + let testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + ObjectUpdatesEffects, + provideMockActions(() => actions), + { + provide: NotificationsService, + useValue: { + remove: (notification) => { /* empty */ + } + } + }, + ], + }); + })); + + beforeEach(() => { + testURL = 'www.dspace.org/dspace7'; + testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + updatesEffects = TestBed.get(ObjectUpdatesEffects); + (updatesEffects as any).actionMap[testURL] = new Subject(); + }); + + describe('mapLastActions$', () => { + describe('When any ObjectUpdatesAction is triggered', () => { + let action; + let emittedAction; + beforeEach(() => { + action = new RemoveObjectUpdatesAction(testURL); + }); + it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { + action = new RemoveObjectUpdatesAction(testURL); + actions = hot('--a-', { a: action }); + (updatesEffects as any).actionMap[testURL].subscribe((act) => emittedAction = act); + const expected = cold('--b-', { b: undefined }); + + expect(updatesEffects.mapLastActions$).toBeObservable(expected); + expect(emittedAction).toBe(action); + }); + }); + }); + + describe('removeAfterDiscardOrReinstateOnUndo$', () => { + describe('When an ObjectUpdatesActionTypes.DISCARD action is triggered', () => { + let infoNotification: INotification; + let removeAction; + describe('When there is no user interactions before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 0; + removeAction = new RemoveObjectUpdatesAction(testURL) + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + filter(((action) => hasValue(action)))) + .subscribe((t) => { + expect(t).toEqual(removeAction); + } + ) + ; + }); + }); + + describe('When there a REINSTATE action is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return an action with type NO_ACTION', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { + expect(t).toEqual({ type: 'NO_ACTION' }); + } + ); + }); + }); + + describe('When there any ObjectUpdates action - other than REINSTATE - is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => + expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) + ); + }); + }); + }); + }); +}); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts new file mode 100644 index 0000000000..ae49071dc1 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ObjectUpdatesActionTypes, + RemoveObjectUpdatesAction +} from './object-updates.actions'; +import { delay, map, switchMap, take, tap } from 'rxjs/operators'; +import { of as observableOf, race as observableRace, Subject } from 'rxjs'; +import { hasNoValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +/** + * NGRX effects for ObjectUpdatesActions + */ +@Injectable() +export class ObjectUpdatesEffects { + /** + * Map that keeps track of the latest ObjectUpdatesAction for each page's url + */ + private actionMap: { + /* Use Subject instead of BehaviorSubject: + we only want Actions that are fired while we're listening + actions that were previously fired do not matter anymore + */ + [url: string]: Subject + } = {}; + + /** + * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key + */ + @Effect({ dispatch: false }) mapLastActions$ = this.actions$ + .pipe( + ofType(...Object.values(ObjectUpdatesActionTypes)), + map((action: DiscardObjectUpdatesAction) => { + const url: string = action.payload.url; + if (hasNoValue(this.actionMap[url])) { + this.actionMap[url] = new Subject(); + } + this.actionMap[url].next(action); + } + ) + ); + + /** + * Effect that checks whether the removeAction's notification timeout ends before a user triggers another ObjectUpdatesAction + * When no ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned + * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + */ + @Effect() removeAfterDiscardOrReinstateOnUndo$ = this.actions$ + .pipe( + ofType(ObjectUpdatesActionTypes.DISCARD), + switchMap((action: DiscardObjectUpdatesAction) => { + const url: string = action.payload.url; + const notification: INotification = action.payload.notification; + const timeOut = notification.options.timeOut; + return observableRace( + // Either wait for the delay and perform a remove action + observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)), + // Or wait for a a user action + this.actionMap[url].pipe( + take(1), + tap(() => this.notificationsService.remove(notification)), + map((updateAction: ObjectUpdatesAction) => { + if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { + // If someone reinstated, do nothing, just let the reinstating happen + return { type: 'NO_ACTION' } + } else { + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return new RemoveObjectUpdatesAction(action.payload.url); + } + }) + ) + ) + } + ) + ); + + constructor(private actions$: Actions, + private notificationsService: NotificationsService) { + + } + +} diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts new file mode 100644 index 0000000000..f5698b9b78 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -0,0 +1,274 @@ +import * as deepFreeze from 'deep-freeze'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; + +class NullAction extends RemoveFieldUpdateAction { + type = null; + payload = null; + + constructor() { + super(null, null); + } +} + +const identifiable1 = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, John' +}; + +const identifiable1update = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, James' +}; +const identifiable2 = { + uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241', + key: 'dc.title', + language: null, + value: 'New title' +}; +const identifiable3 = { + uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e', + key: 'dc.description.abstract', + language: null, + value: 'Unchanged value' +}; + +const modDate = new Date(2010, 2, 11); +const uuid = identifiable1.uuid; +const url = 'test-object.url/edit'; +describe('objectUpdatesReducer', () => { + const testState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.titl', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + const discardedTestState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + }, + lastModified: modDate + }, + [url + OBJECT_UPDATES_TRASH_PATH]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.titl', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = objectUpdatesReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty object', () => { + const action = new NullAction(); + const initialState = objectUpdatesReducer(undefined, action); + + expect(initialState).toEqual({}); + }); + + it('should perform the INITIALIZE_FIELDS action without affecting the previous state', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { + const action = new SetEditableFieldUpdateAction(url, uuid, false); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the ADD_FIELD action without affecting the previous state', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the DISCARD action without affecting the previous state', () => { + const action = new DiscardObjectUpdatesAction(url, null); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REINSTATE action without affecting the previous state', () => { + const action = new ReinstateObjectUpdatesAction(url); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE_FIELD action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); + + const expectedState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + }, + fieldUpdates: {}, + lastModified: modDate + } + }; + const newState = objectUpdatesReducer(testState, action); + expect(newState).toEqual(expectedState); + }); + + it('should set the given field\'s fieldStates when the SET_EDITABLE_FIELD action is dispatched, based on the payload', () => { + const action = new SetEditableFieldUpdateAction(url, identifiable3.uuid, true); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy(); + }); + + it('should set the given field\'s fieldStates when the SET_VALID_FIELD action is dispatched, based on the payload', () => { + const action = new SetValidFieldUpdateAction(url, identifiable3.uuid, false); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].isValid).toBeFalsy(); + }); + + it('should add a given field\'s update to the state when the ADD_FIELD action is dispatched, based on the payload', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[identifiable1.uuid].field).toEqual(identifiable1update); + expect(newState[url].fieldUpdates[identifiable1.uuid].changeType).toEqual(FieldChangeType.UPDATE); + }); + + it('should discard a given url\'s updates from the state when the DISCARD action is dispatched, based on the payload', () => { + const action = new DiscardObjectUpdatesAction(url, null); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates).toEqual({}); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toEqual(testState[url]); + }); + + it('should reinstate a given url\'s updates from the state when the REINSTATE action is dispatched, based on the payload', () => { + const action = new ReinstateObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState).toEqual(testState); + }); + + it('should remove a given url\'s updates from the state when the REMOVE action is dispatched, based on the payload', () => { + const action = new RemoveObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState[url].fieldUpdates).toBeUndefined(); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); + }); + + it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[uuid]).toBeUndefined(); + }); +}); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts new file mode 100644 index 0000000000..c0f10ff92a --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -0,0 +1,332 @@ +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ObjectUpdatesAction, + ObjectUpdatesActionTypes, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; + +/** + * Path where discarded objects are saved + */ +export const OBJECT_UPDATES_TRASH_PATH = '/trash'; + +/** + * The state for a single field + */ +export interface FieldState { + editable: boolean, + isNew: boolean, + isValid: boolean +} + +/** + * A list of states for all the fields for a single page, mapped by uuid + */ +export interface FieldStates { + [uuid: string]: FieldState; +} + +/** + * Represents every object that has a UUID + */ +export interface Identifiable { + uuid: string +} + +/** + * The state of a single field update + */ +export interface FieldUpdate { + field: Identifiable, + changeType: FieldChangeType +} + +/** + * The states of all field updates available for a single page, mapped by uuid + */ +export interface FieldUpdates { + [uuid: string]: FieldUpdate; +} + +/** + * The updated state of a single page + */ +export interface ObjectUpdatesEntry { + fieldStates: FieldStates; + fieldUpdates: FieldUpdates + lastModified: Date; +} + +/** + * The updated state of all pages, mapped by the page URL + */ +export interface ObjectUpdatesState { + [url: string]: ObjectUpdatesEntry; +} + +/** + * Initial state for an existing initialized field + */ +const initialFieldState = { editable: false, isNew: false, isValid: true }; + +/** + * Initial state for a newly added field + */ +const initialNewFieldState = { editable: true, isNew: true, isValid: undefined }; + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState = Object.create(null); + +/** + * Reducer method to calculate the next ObjectUpdates state, based on the current state and the ObjectUpdatesAction + * @param state The current state + * @param action The action to perform on the current state + */ +export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState { + switch (action.type) { + case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { + return initializeFieldsUpdate(state, action as InitializeFieldsAction); + } + case ObjectUpdatesActionTypes.ADD_FIELD: { + return addFieldUpdate(state, action as AddFieldUpdateAction); + } + case ObjectUpdatesActionTypes.DISCARD: { + return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REINSTATE: { + return reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REMOVE: { + return removeObjectUpdates(state, action as RemoveObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REMOVE_FIELD: { + return removeFieldUpdate(state, action as RemoveFieldUpdateAction); + } + case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: { + return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction); + } + case ObjectUpdatesActionTypes.SET_VALID_FIELD: { + return setValidFieldUpdate(state, action as SetValidFieldUpdateAction); + } + default: { + return state; + } + } +} + +/** + * Initialize the state for a specific url and store all its fields in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { + const url: string = action.payload.url; + const fields: Identifiable[] = action.payload.fields; + const lastModifiedServer: Date = action.payload.lastModified; + const fieldStates = createInitialFieldStates(fields); + const newPageState = Object.assign( + {}, + state[url], + { fieldStates: fieldStates }, + { fieldUpdates: {} }, + { lastModified: lastModifiedServer } + ); + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Add a new update for a specific field to the store + * @param state The current state + * @param action The action to perform on the current state + */ +function addFieldUpdate(state: any, action: AddFieldUpdateAction) { + const url: string = action.payload.url; + const field: Identifiable = action.payload.field; + const changeType: FieldChangeType = action.payload.changeType; + const pageState: ObjectUpdatesEntry = state[url] || {}; + + let states = pageState.fieldStates; + if (changeType === FieldChangeType.ADD) { + states = Object.assign({}, { [field.uuid]: initialNewFieldState }, pageState.fieldStates) + } + + let fieldUpdate: any = pageState.fieldUpdates[field.uuid] || {}; + const newChangeType = determineChangeType(fieldUpdate.changeType, changeType); + + fieldUpdate = Object.assign({}, { field, changeType: newChangeType }); + + const fieldUpdates = Object.assign({}, pageState.fieldUpdates, { [field.uuid]: fieldUpdate }); + + const newPageState = Object.assign({}, pageState, + { fieldStates: states }, + { fieldUpdates: fieldUpdates }); + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Discard all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { + const url: string = action.payload.url; + const pageState: ObjectUpdatesEntry = state[url]; + const newFieldStates = {}; + Object.keys(pageState.fieldStates).forEach((uuid: string) => { + const fieldState: FieldState = pageState.fieldStates[uuid]; + if (!fieldState.isNew) { + /* After discarding we don't want the reset fields to stay editable or invalid */ + newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false, isValid: true }); + } + }); + + const discardedPageState = Object.assign({}, pageState, { + fieldUpdates: {}, + fieldStates: newFieldStates + }); + return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); +} + +/** + * Reinstate all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction) { + const url: string = action.payload.url; + const trashState = state[url + OBJECT_UPDATES_TRASH_PATH]; + + const newState = Object.assign({}, state, { [url]: trashState }); + delete newState[url + OBJECT_UPDATES_TRASH_PATH]; + return newState; +} + +/** + * Remove all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeObjectUpdates(state: any, action: RemoveObjectUpdatesAction) { + const url: string = action.payload.url; + return removeObjectUpdatesByURL(state, url); +} + +/** + * Remove all updates for a specific url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeObjectUpdatesByURL(state: any, url: string) { + const newState = Object.assign({}, state); + delete newState[url + OBJECT_UPDATES_TRASH_PATH]; + return newState; +} + +/** + * Discard the update for a specific action's url and field UUID in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + let newPageState: ObjectUpdatesEntry = state[url]; + if (hasValue(newPageState)) { + const newUpdates: FieldUpdates = Object.assign({}, newPageState.fieldUpdates); + if (hasValue(newUpdates[uuid])) { + delete newUpdates[uuid]; + } + const newFieldStates: FieldStates = Object.assign({}, newPageState.fieldStates); + if (hasValue(newFieldStates[uuid])) { + /* When resetting, make field not editable */ + if (newFieldStates[uuid].isNew) { + /* If this field was added, just throw it away */ + delete newFieldStates[uuid]; + } else { + newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false, isValid: true }); + } + } + newPageState = Object.assign({}, state[url], { + fieldUpdates: newUpdates, + fieldStates: newFieldStates + }); + } + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Determine the most prominent FieldChangeType, ordered as follows: + * undefined < UPDATE < ADD < REMOVE + * @param oldType The current type + * @param newType The new type that should possibly override the new type + */ +function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType): FieldChangeType { + if (hasNoValue(newType)) { + return oldType; + } + if (hasNoValue(oldType)) { + return newType; + } + return oldType.valueOf() > newType.valueOf() ? oldType : newType; +} + +/** + * Set the editable state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ +function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + const editable: boolean = action.payload.editable; + + const pageState: ObjectUpdatesEntry = state[url]; + + const fieldState = pageState.fieldStates[uuid]; + const newFieldState = Object.assign({}, fieldState, { editable }); + + const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState }); + + const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates }); + + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Set the isValid state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ +function setValidFieldUpdate(state: any, action: SetValidFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + const isValid: boolean = action.payload.isValid; + + const pageState: ObjectUpdatesEntry = state[url]; + + const fieldState = pageState.fieldStates[uuid]; + const newFieldState = Object.assign({}, fieldState, { isValid }); + + const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState }); + + const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates }); + + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Method to create an initial FieldStates object based on a list of Identifiable objects + * @param fields Identifiable objects + */ +function createInitialFieldStates(fields: Identifiable[]) { + const uuids = fields.map((field: Identifiable) => field.uuid); + const fieldStates = {}; + uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); + return fieldStates; +} diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts new file mode 100644 index 0000000000..e9fc4652b0 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -0,0 +1,254 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { ObjectUpdatesService } from './object-updates.service'; +import { + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SetEditableFieldUpdateAction +} from './object-updates.actions'; +import { of as observableOf } from 'rxjs'; +import { Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; + +describe('ObjectUpdatesService', () => { + let service: ObjectUpdatesService; + let store: Store; + const value = 'test value'; + const url = 'test-url.com/dspace'; + const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320' }; + const identifiable1Updated = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', value: value }; + const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; + const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; + const identifiables = [identifiable1, identifiable2]; + + const fieldUpdates = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + const modDate = new Date(2010, 2, 11); + + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate + }; + store = new Store(undefined, undefined, undefined); + spyOn(store, 'dispatch'); + service = new ObjectUpdatesService(store); + + spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); + spyOn(service as any, 'getFieldState').and.callFake((uuid) => { + return observableOf(fieldStates[uuid]); + }); + spyOn(service as any, 'saveFieldUpdate'); + }); + + describe('initialize', () => { + it('should dispatch an INITIALIZE action with the correct URL, initial identifiables and the last modified date', () => { + service.initialize(url, identifiables, modDate); + expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate)); + }); + }); + + describe('getFieldUpdates', () => { + it('should return the list of all fields, including their update if there is one', () => { + const result$ = service.getFieldUpdates(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('isEditable', () => { + it('should return false if this identifiable is currently not editable in the store', () => { + const result$ = service.isEditable(url, identifiable1.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently editable in the store', () => { + const result$ = service.isEditable(url, identifiable2.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('isValid', () => { + it('should return false if this identifiable is currently not valid in the store', () => { + const result$ = service.isValid(url, identifiable2.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently valid in the store', () => { + const result$ = service.isValid(url, identifiable1.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('saveAddFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.ADD', () => { + service.saveAddFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.ADD); + }); + }); + + describe('saveRemoveFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.REMOVE', () => { + service.saveRemoveFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.REMOVE); + }); + }); + + describe('saveChangeFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.UPDATE', () => { + service.saveChangeFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.UPDATE); + }); + }); + + describe('setEditableFieldUpdate', () => { + it('should dispatch a SetEditableFieldUpdateAction action with the correct URL, uuid and true when true was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, true); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, true)); + }); + + it('should dispatch an SetEditableFieldUpdateAction action with the correct URL, uuid and false when false was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, false); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, false)); + }); + }); + + describe('discardFieldUpdates', () => { + it('should dispatch a DiscardObjectUpdatesAction action with the correct URL and passed notification ', () => { + const undoNotification = new Notification('id', NotificationType.Info, 'undo'); + service.discardFieldUpdates(url, undoNotification); + expect(store.dispatch).toHaveBeenCalledWith(new DiscardObjectUpdatesAction(url, undoNotification)); + }); + }); + + describe('reinstateFieldUpdates', () => { + it('should dispatch a ReinstateObjectUpdatesAction action with the correct URL ', () => { + service.reinstateFieldUpdates(url); + expect(store.dispatch).toHaveBeenCalledWith(new ReinstateObjectUpdatesAction(url)); + }); + }); + + describe('removeSingleFieldUpdate', () => { + it('should dispatch a RemoveFieldUpdateAction action with the correct URL and uuid', () => { + service.removeSingleFieldUpdate(url, identifiable1.uuid); + expect(store.dispatch).toHaveBeenCalledWith(new RemoveFieldUpdateAction(url, identifiable1.uuid)); + }); + }); + + describe('getUpdatedFields', () => { + it('should return the list of all metadata fields with their new values', () => { + const result$ = service.getUpdatedFields(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = [identifiable1Updated, identifiable2, identifiable3]; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('hasUpdates', () => { + it('should return true when there are updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + describe('when updates are emtpy', () => { + beforeEach(() => { + (service as any).getObjectEntry.and.returnValue(observableOf({})) + }); + + it('should return false when there are no updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('isReinstatable', () => { + + describe('when updates are not emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(true)); + }); + + it('should return true', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('when updates are emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(false)); + }); + + it('should return false', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('getLastModified', () => { + it('should return true when hasUpdates returns true', () => { + const result$ = service.getLastModified(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = modDate; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + +}); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts new file mode 100644 index 0000000000..85e17b5b2f --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -0,0 +1,268 @@ +import { Injectable } from '@angular/core'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { coreSelector, CoreState } from '../../core.reducers'; +import { + FieldState, + FieldUpdates, + Identifiable, OBJECT_UPDATES_TRASH_PATH, + ObjectUpdatesEntry, + ObjectUpdatesState +} from './object-updates.reducer'; +import { Observable } from 'rxjs'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +function objectUpdatesStateSelector(): MemoizedSelector { + return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); +} + +function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector { + return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]); +} + +function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); +} + +/** + * Service that dispatches and reads from the ObjectUpdates' state in the store + */ +@Injectable() +export class ObjectUpdatesService { + constructor(private store: Store) { + + } + + /** + * Method to dispatch an InitializeFieldsAction to the store + * @param url The page's URL for which the changes are being mapped + * @param fields The initial fields for the page's object + * @param lastModified The date the object was last modified + */ + initialize(url, fields: Identifiable[], lastModified: Date): void { + this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); + } + + /** + * Method to dispatch an AddFieldUpdateAction to the store + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + * @param changeType The last type of change applied to this field + */ + private saveFieldUpdate(url: string, field: Identifiable, changeType: FieldChangeType) { + this.store.dispatch(new AddFieldUpdateAction(url, field, changeType)) + } + + /** + * Request the ObjectUpdatesEntry state for a specific URL + * @param url The URL to filter by + */ + private getObjectEntry(url: string): Observable { + return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url))); + } + + /** + * Request the getFieldState state for a specific URL and UUID + * @param url The URL to filter by + * @param uuid The field's UUID to filter by + */ + private getFieldState(url: string, uuid: string): Observable { + return this.store.pipe(select(filterByUrlAndUUIDFieldStateSelector(url, uuid))); + } + + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + */ + getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + let fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (isEmpty(fieldUpdate)) { + const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); + fieldUpdate = { field: identifiable, changeType: undefined }; + } + fieldUpdates[uuid] = fieldUpdate; + }); + return fieldUpdates; + })) + } + + /** + * Method to check if a specific field is currently editable in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ + isEditable(url: string, uuid: string): Observable { + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.editable), + distinctUntilChanged() + ) + } + + /** + * Method to check if a specific field is currently valid in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ + isValid(url: string, uuid: string): Observable { + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.isValid), + distinctUntilChanged() + ) + } + + /** + * Method to check if a specific page is currently valid in the store + * @param url The URL of the page + */ + isValidPage(url: string): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe( + map((entry: ObjectUpdatesEntry) => { + return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0 + }), + distinctUntilChanged() + ) + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.ADD + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveAddFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.ADD); + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.REMOVE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveRemoveFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.REMOVE); + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.UPDATE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveChangeFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); + } + + /** + * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param editable The new value of editable in the store for this field + */ + setEditableFieldUpdate(url: string, uuid: string, editable: boolean) { + this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable)); + } + + /** + * Dispatches a SetValidFieldUpdateAction to the store to set a field's isValid state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param valid The new value of isValid in the store for this field + */ + setValidFieldUpdate(url: string, uuid: string, valid: boolean) { + this.store.dispatch(new SetValidFieldUpdateAction(url, uuid, valid)); + } + + /** + * Method to dispatch an DiscardObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be discarded + * @param undoNotification The notification which is should possibly be canceled + */ + discardFieldUpdates(url: string, undoNotification: INotification) { + this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification)); + } + + /** + * Method to dispatch an ReinstateObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be reinstated + */ + reinstateFieldUpdates(url: string) { + this.store.dispatch(new ReinstateObjectUpdatesAction(url)); + } + + /** + * Method to dispatch an RemoveFieldUpdateAction to the store + * @param url The page's URL for which the changes should be removed + */ + removeSingleFieldUpdate(url: string, uuid) { + this.store.dispatch(new RemoveFieldUpdateAction(url, uuid)); + } + + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a list of updates fields + * @param url The URL of the page for which the updated fields should be requested + * @param initialFields The initial values of the fields + */ + getUpdatedFields(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fields: Identifiable[] = []; + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + const fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (hasNoValue(fieldUpdate) || fieldUpdate.changeType !== FieldChangeType.REMOVE) { + let field; + if (isNotEmpty(fieldUpdate)) { + field = fieldUpdate.field; + } else { + field = initialFields.find((object: Identifiable) => object.uuid === uuid); + } + fields.push(field); + } + }); + return fields; + })) + } + + /** + * Checks if the page currently has updates in the store or not + * @param url The page's url to check for in the store + */ + hasUpdates(url: string): Observable { + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); + } + + /** + * Checks if the page currently is reinstatable in the store or not + * @param url The page's url to check for in the store + */ + isReinstatable(url: string): Observable { + return this.hasUpdates(url + OBJECT_UPDATES_TRASH_PATH) + } + + /** + * Request the current lastModified date stored for the updates in the store + * @param url The page's url to check for in the store + */ + getLastModified(url: string): Observable { + return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); + } +} diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index fe9a3a241e..c85a437ffc 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 { MetadataMap, MetadataValue } from '../shared/metadata.interfaces'; +import { MetadataMap, MetadataValue } from '../shared/metadata.models'; @Injectable() export class SearchResponseParsingService implements ResponseParsingService { @@ -28,7 +28,7 @@ export class SearchResponseParsingService implements ResponseParsingService { const mdMap: MetadataMap = {}; if (hhObject) { for (const key of Object.keys(hhObject)) { - const value: MetadataValue = { value: hhObject[key].join('...'), language: null }; + const value: MetadataValue = Object.assign(new MetadataValue(), { value: hhObject[key].join('...'), language: null }); mdMap[key] = [ value ]; } } diff --git a/src/app/core/eperson/eperson-response-parsing.service.ts b/src/app/core/eperson/eperson-response-parsing.service.ts index 56429340da..6c591b0b99 100644 --- a/src/app/core/eperson/eperson-response-parsing.service.ts +++ b/src/app/core/eperson/eperson-response-parsing.service.ts @@ -9,9 +9,9 @@ import { BaseResponseParsingService } from '../data/base-response-parsing.servic import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; import { ResourceType } from '../shared/resource-type'; +import { DSpaceObject } from '../shared/dspace-object.model'; /** * Provides method to parse response from eperson endpoint. @@ -31,7 +31,7 @@ export class EpersonResponseParsingService extends BaseResponseParsingService im parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { - const epersonDefinition = this.process(data.payload, request.href); + const epersonDefinition = this.process(data.payload, request.href); return new EpersonSuccessResponse(epersonDefinition[Object.keys(epersonDefinition)[0]], data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( diff --git a/src/app/core/eperson/eperson.service.ts b/src/app/core/eperson/eperson.service.ts index 6aff82421d..70ecf3f59e 100644 --- a/src/app/core/eperson/eperson.service.ts +++ b/src/app/core/eperson/eperson.service.ts @@ -1,13 +1,12 @@ import { Observable } from 'rxjs'; import { FindAllOptions } from '../data/request.models'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { DataService } from '../data/data.service'; import { CacheableObject } from '../cache/object-cache.reducer'; /** * An abstract class that provides methods to make HTTP request to eperson endpoint. */ -export abstract class EpersonService extends DataService { +export abstract class EpersonService extends DataService { public getBrowseEndpoint(options: FindAllOptions): Observable { return this.halService.getEndpoint(this.linkPath); diff --git a/src/app/core/eperson/group-eperson.service.ts b/src/app/core/eperson/group-eperson.service.ts index 0e12c55139..07a1bb6aba 100644 --- a/src/app/core/eperson/group-eperson.service.ts +++ b/src/app/core/eperson/group-eperson.service.ts @@ -9,7 +9,6 @@ import { EpersonService } from './eperson.service'; import { RequestService } from '../data/request.service'; import { FindAllOptions } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NormalizedGroup } from './models/normalized-group.model'; import { Group } from './models/group.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; @@ -25,13 +24,13 @@ import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; * Provides methods to retrieve eperson group resources. */ @Injectable() -export class GroupEpersonService extends EpersonService { +export class GroupEpersonService extends EpersonService { protected linkPath = 'groups'; protected browseEndpoint = ''; protected forceBypassCache = false; constructor( - protected comparator: DSOChangeAnalyzer, + protected comparator: DSOChangeAnalyzer, protected dataBuildService: NormalizedObjectBuildService, protected http: HttpClient, protected notificationsService: NotificationsService, diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index 45d26761b0..7d2138b633 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -19,4 +19,8 @@ export class EPerson extends DSpaceObject { public selfRegistered: boolean; + /** Getter to retrieve the EPerson's full name as a string */ + get name(): string { + return this.firstMetadataValue('eperson.firstname') + ' ' + this.firstMetadataValue('eperson.lastname'); + } } diff --git a/src/app/core/eperson/models/group.model.ts b/src/app/core/eperson/models/group.model.ts index 6ebedba48d..27fa0ef595 100644 --- a/src/app/core/eperson/models/group.model.ts +++ b/src/app/core/eperson/models/group.model.ts @@ -6,5 +6,7 @@ export class Group extends DSpaceObject { public handle: string; + public name: string; + public permanent: boolean; } diff --git a/src/app/core/eperson/models/normalized-eperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts index 4f2a5edcd4..1032c84db1 100644 --- a/src/app/core/eperson/models/normalized-eperson.model.ts +++ b/src/app/core/eperson/models/normalized-eperson.model.ts @@ -9,7 +9,7 @@ import { NormalizedGroup } from './normalized-group.model'; @mapsTo(EPerson) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { @autoserialize public handle: string; diff --git a/src/app/core/eperson/models/normalized-group.model.ts b/src/app/core/eperson/models/normalized-group.model.ts index 3189adbc8f..8cfd24524c 100644 --- a/src/app/core/eperson/models/normalized-group.model.ts +++ b/src/app/core/eperson/models/normalized-group.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; @@ -7,7 +7,10 @@ import { Group } from './group.model'; @mapsTo(Group) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { + + @autoserializeAs(NormalizedGroup) + groups: Group[]; @autoserialize public handle: string; diff --git a/src/app/core/integration/models/authority.value.ts b/src/app/core/integration/models/authority.value.ts index d493642213..31cb0a5787 100644 --- a/src/app/core/integration/models/authority.value.ts +++ b/src/app/core/integration/models/authority.value.ts @@ -2,12 +2,12 @@ import { IntegrationModel } from './integration.model'; import { isNotEmpty } from '../../../shared/empty.util'; import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { MetadataValue } from '../../shared/metadata.interfaces'; +import { MetadataValueInterface } from '../../shared/metadata.models'; /** * Class representing an authority object */ -export class AuthorityValue extends IntegrationModel implements MetadataValue { +export class AuthorityValue extends IntegrationModel implements MetadataValueInterface { /** * The identifier of this authority diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts index 67751ee9a9..4ecc215dc7 100644 --- a/src/app/core/json-patch/json-patch-operations.service.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -23,6 +23,7 @@ import { } from './json-patch-operations.actions'; import { MockStore } from '../../shared/testing/mock-store'; import { RequestEntry } from '../data/request.reducer'; +import { catchError } from 'rxjs/operators'; class TestService extends JsonPatchOperationsService { protected linkPath = ''; @@ -176,7 +177,9 @@ describe('JsonPatchOperationsService test suite', () => { it('should dispatch a new RollbacktPatchOperationsAction', () => { const expectedAction = new RollbacktPatchOperationsAction(testJsonPatchResourceType, undefined); - scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType) + .pipe(catchError(() => observableOf({}))) + .subscribe()); scheduler.flush(); expect(store.dispatch).toHaveBeenCalledWith(expectedAction); @@ -237,7 +240,9 @@ describe('JsonPatchOperationsService test suite', () => { it('should dispatch a new RollbacktPatchOperationsAction', () => { const expectedAction = new RollbacktPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId); - scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId) + .pipe(catchError(() => observableOf({}))) + .subscribe()); scheduler.flush(); expect(store.dispatch).toHaveBeenCalledWith(expectedAction); diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts index 7ca59e8b39..90eaf87a0e 100644 --- a/src/app/core/json-patch/json-patch-operations.service.ts +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -1,4 +1,4 @@ -import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; import { distinctUntilChanged, filter, find, flatMap, map, partition, take, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; @@ -94,7 +94,7 @@ export abstract class JsonPatchOperationsService this.store.dispatch(new RollbacktPatchOperationsAction(resourceType, resourceId))), - flatMap((response: ErrorResponse) => observableOf(new Error(`Couldn't patch operations`)))), + flatMap((error: ErrorResponse) => observableThrowError(error))), successResponse$.pipe( filter((response: PostPatchSuccessResponse) => isNotEmpty(response)), tap(() => this.store.dispatch(new CommitPatchOperationsAction(resourceType, resourceId))), diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index d6aec339a1..cfb5a0751d 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -37,7 +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'; +import { MetadataValue } from '../shared/metadata.models'; /* tslint:disable:max-classes-per-file */ @Component({ diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 736bf11923..a95fc73d33 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -81,7 +81,7 @@ export class MetadataService { this.clearMetaTags(); } if (routeInfo.data.value.title) { - this.translate.get(routeInfo.data.value.title).pipe(take(1)).subscribe((translatedTitle: string) => { + this.translate.get(routeInfo.data.value.title, routeInfo.data.value).pipe(take(1)).subscribe((translatedTitle: string) => { this.addMetaTag('title', translatedTitle); this.title.setTitle(translatedTitle); }); diff --git a/src/app/core/metadata/metadatafield.model.ts b/src/app/core/metadata/metadatafield.model.ts index f9b5155649..ba28b59d0e 100644 --- a/src/app/core/metadata/metadatafield.model.ts +++ b/src/app/core/metadata/metadatafield.model.ts @@ -1,6 +1,7 @@ import { MetadataSchema } from './metadataschema.model'; import { autoserialize } from 'cerialize'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { isNotEmpty } from '../../shared/empty.util'; export class MetadataField implements ListableObject { @autoserialize @@ -20,4 +21,12 @@ export class MetadataField implements ListableObject { @autoserialize schema: MetadataSchema; + + toString(separator: string = '.'): string { + let key = this.schema.prefix + separator + this.element; + if (isNotEmpty(this.qualifier)) { + key += separator + this.qualifier; + } + return key; + } } diff --git a/src/app/core/metadata/normalized-metadata-schema.model.ts b/src/app/core/metadata/normalized-metadata-schema.model.ts index 844efd232f..c121938940 100644 --- a/src/app/core/metadata/normalized-metadata-schema.model.ts +++ b/src/app/core/metadata/normalized-metadata-schema.model.ts @@ -1,7 +1,6 @@ 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'; @@ -9,7 +8,7 @@ import { MetadataSchema } from './metadataschema.model'; * Normalized class for a DSpace MetadataSchema */ @mapsTo(MetadataSchema) -export class NormalizedMetadataSchema extends NormalizedObject implements CacheableObject, ListableObject { +export class NormalizedMetadataSchema extends NormalizedObject implements ListableObject { /** * The unique identifier for this schema */ diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 969024e330..8fa1ca893a 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -29,18 +29,24 @@ import { import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { hasValue, hasNoValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service'; import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; -import { configureRequest, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; +import { + configureRequest, + getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; import { createSelector, select, Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers'; import { MetadataRegistryCancelFieldAction, - MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, MetadataRegistryDeselectAllSchemaAction, + MetadataRegistryCancelSchemaAction, + MetadataRegistryDeselectAllFieldAction, + MetadataRegistryDeselectAllSchemaAction, MetadataRegistryDeselectFieldAction, MetadataRegistryDeselectSchemaAction, MetadataRegistryEditFieldAction, @@ -167,6 +173,47 @@ export class RegistryService { return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } + /** + * Retrieve all existing metadata fields as a paginated list + * @param pagination Pagination options to determine which page of metadata fields should be requested + * When no pagination is provided, all metadata fields are requested in one large page + * @returns an observable that emits a remote data object with a page of metadata fields + */ + public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable>> { + if (hasNoValue(pagination)) { + pagination = { currentPage: 1, pageSize: 10000 } as any; + } + const requestObs = this.getMetadataFieldsRequestObs(pagination); + + const requestEntryObs = requestObs.pipe( + flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) + ); + + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse) + ); + + const metadatafieldsObs: Observable = rmrObs.pipe( + map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields), + map((metadataFields: MetadataField[]) => metadataFields) + ); + + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + + map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo) + ); + + const payloadObs = observableCombineLatest(metadatafieldsObs, pageInfoObs).pipe( + map(([metadatafields, pageInfo]) => { + return new PaginatedList(pageInfo, metadatafields); + }) + ); + + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + } + public getBitstreamFormats(pagination: PaginationComponentOptions): Observable>> { const requestObs = this.getBitstreamFormatsRequestObs(pagination); @@ -239,6 +286,26 @@ export class RegistryService { ); } + private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable { + return this.halService.getEndpoint(this.metadataFieldsPath).pipe( + map((url: string) => { + const args: string[] = []; + args.push(`size=${pagination.pageSize}`); + args.push(`page=${pagination.currentPage - 1}`); + if (isNotEmpty(args)) { + url = new URLCombiner(url, `?${args.join('&')}`).toString(); + } + const request = new GetRequest(this.requestService.generateRequestId(), url); + return Object.assign(request, { + getResponseParser(): GenericConstructor { + return RegistryMetadatafieldsResponseParsingService; + } + }); + }), + tap((request: RestRequest) => this.requestService.configure(request)), + ); + } + private getBitstreamFormatsRequestObs(pagination: PaginationComponentOptions): Observable { return this.halService.getEndpoint(this.bitstreamFormatsPath).pipe( map((url: string) => { @@ -495,4 +562,21 @@ export class RegistryService { } }); } + + /** + * Retrieve a filtered paginated list of metadata fields + * @param query {string} The query to filter the field names by + * @returns an observable that emits a remote data object with a page of metadata fields that match the query + */ + queryMetadataFields(query: string): Observable>> { + return this.getAllMetadataFields().pipe( + map((rd: RemoteData>) => { + const filteredFields: MetadataField[] = rd.payload.page.filter( + (field: MetadataField) => field.toString().indexOf(query) >= 0 + ); + const page: PaginatedList = new PaginatedList(new PageInfo(), filteredFields) + return Object.assign({}, rd, { payload: page }); + }) + ); + } } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 100d4da557..71c6ee7837 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,12 +1,12 @@ -import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces'; -import { Metadata } from './metadata.model'; -import { isEmpty, isNotEmpty, isUndefined } from '../../shared/empty.util'; +import { Observable } from 'rxjs'; + +import { MetadataMap, MetadataValue, MetadataValueFilter, MetadatumViewModel } from './metadata.models'; +import { Metadata } from './metadata.utils'; +import { isUndefined } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { Observable } from 'rxjs'; -import { autoserialize } from 'cerialize'; /** * An abstract model class for a DSpaceObject. @@ -20,13 +20,11 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * The human-readable identifier of this DSpaceObject */ - @autoserialize id: string; /** * The universally unique identifier of this DSpaceObject */ - @autoserialize uuid: string; /** @@ -51,9 +49,15 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * All metadata of this DSpaceObject */ - @autoserialize metadata: MetadataMap; + /** + * Retrieve the current metadata as a list of MetadatumViewModels + */ + get metadataAsList(): MetadatumViewModel[] { + return Metadata.toViewModelList(this.metadata); + } + /** * An array of DSpaceObjects that are direct parents of this DSpaceObject */ diff --git a/src/app/core/shared/metadata.interfaces.ts b/src/app/core/shared/metadata.interfaces.ts deleted file mode 100644 index 3590117ce8..0000000000 --- a/src/app/core/shared/metadata.interfaces.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** 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.models.ts b/src/app/core/shared/metadata.models.ts new file mode 100644 index 0000000000..ab007c15f6 --- /dev/null +++ b/src/app/core/shared/metadata.models.ts @@ -0,0 +1,92 @@ +import * as uuidv4 from 'uuid/v4'; +import { autoserialize, Serialize, Deserialize } from 'cerialize'; +/* tslint:disable:max-classes-per-file */ + +/** A single metadata value and its properties. */ +export interface MetadataValueInterface { + + /** The language. */ + language: string; + + /** The string value. */ + value: string; +} + +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +export interface MetadataMapInterface { + [key: string]: MetadataValueInterface[]; +} + +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +export class MetadataMap implements MetadataMapInterface { + [key: string]: MetadataValue[]; +} + +/** A single metadata value and its properties. */ +export class MetadataValue implements MetadataValueInterface { + /** The uuid. */ + uuid: string = uuidv4(); + + /** The language. */ + @autoserialize + language: string; + + /** The string value. */ + @autoserialize + 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; +} + +export class MetadatumViewModel { + /** The uuid. */ + uuid: string = uuidv4(); + + /** The metadatafield key. */ + key: string; + + /** The language. */ + language: string; + + /** The string value. */ + value: string; + + /** The order. */ + order: number; +} + +/** Serializer used for MetadataMaps. + * This is necessary because Cerialize has trouble instantiating the MetadataValues using their constructor + * when they are inside arrays which also represent the values in a map. + */ +export const MetadataMapSerializer = { + Serialize(map: MetadataMap): any { + const json = {}; + Object.keys(map).forEach((key: string) => { + json[key] = Serialize(map[key], MetadataValue); + }); + return json; + }, + + Deserialize(json: any): MetadataMap { + const metadataMap: MetadataMap = {}; + Object.keys(json).forEach((key: string) => { + metadataMap[key] = Deserialize(json[key], MetadataValue); + }); + return metadataMap; + } +}; +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/shared/metadata.model.spec.ts b/src/app/core/shared/metadata.utils.spec.ts similarity index 55% rename from src/app/core/shared/metadata.model.spec.ts rename to src/app/core/shared/metadata.utils.spec.ts index dfeff8d600..7fbea14b13 100644 --- a/src/app/core/shared/metadata.model.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -1,10 +1,16 @@ import { isUndefined } from '../../shared/empty.util'; -import { MetadataValue, MetadataValueFilter } from './metadata.interfaces'; -import { Metadata } from './metadata.model'; +import * as uuidv4 from 'uuid/v4'; +import { + MetadataMap, + MetadataValue, + MetadataValueFilter, + MetadatumViewModel +} from './metadata.models'; +import { Metadata } from './metadata.utils'; const mdValue = (value: string, language?: string): MetadataValue => { - return { value: value, language: isUndefined(language) ? null : language }; -} + return { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language }; +}; const dcDescription = mdValue('Some description'); const dcAbstract = mdValue('Some abstract'); @@ -13,19 +19,27 @@ const dcTitle1 = mdValue('Title 1'); const dcTitle2 = mdValue('Title 2', 'en_US'); const bar = mdValue('Bar'); -const singleMap = { 'dc.title': [ dcTitle0 ] }; +const singleMap = { 'dc.title': [dcTitle0] }; const multiMap = { - 'dc.description': [ dcDescription ], - 'dc.description.abstract': [ dcAbstract ], - 'dc.title': [ dcTitle1, dcTitle2 ], - 'foo': [ bar ] + 'dc.description': [dcDescription], + 'dc.description.abstract': [dcAbstract], + 'dc.title': [dcTitle1, dcTitle2], + 'foo': [bar] }; +const multiViewModelList = [ + { key: 'dc.description', ...dcDescription, order: 0 }, + { key: 'dc.description.abstract', ...dcAbstract, order: 0 }, + { key: 'dc.title', ...dcTitle1, order: 0 }, + { key: 'dc.title', ...dcTitle2, order: 1 }, + { key: 'foo', ...bar, order: 0 } +]; + const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => { - const keys = keyOrKeys instanceof Array ? keyOrKeys : [ keyOrKeys ]; + 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)), () => { + + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { const result = fn(mapOrMaps, keys, filter); let shouldReturn; if (resultKind === 'boolean') { @@ -34,7 +48,7 @@ const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => shouldReturn = 'undefined'; } else if (expected instanceof Array) { shouldReturn = 'an array with ' + expected.length + ' ' + (expected.length > 1 ? 'ordered ' : '') - + resultKind + (expected.length !== 1 ? 's' : ''); + + resultKind + (expected.length !== 1 ? 's' : ''); } else { shouldReturn = 'a ' + resultKind; } @@ -57,30 +71,30 @@ describe('Metadata', () => { }); describe('with singleMap', () => { testAll(singleMap, 'foo', []); - testAll(singleMap, '*', [ dcTitle0 ]); + testAll(singleMap, '*', [dcTitle0]); testAll(singleMap, '*', [], { value: 'baz' }); - testAll(singleMap, 'dc.title', [ dcTitle0 ]); - testAll(singleMap, 'dc.*', [ dcTitle0 ]); + 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 ]); + 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 ]); + 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 ]); + 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]); }); }); @@ -93,10 +107,10 @@ describe('Metadata', () => { testAllValues({}, '*', []); }); describe('with singleMap', () => { - testAllValues([ singleMap, multiMap ], '*', [ dcTitle0.value ]); + testAllValues([singleMap, multiMap], '*', [dcTitle0.value]); }); describe('with [ multiMap, singleMap ]', () => { - testAllValues([ multiMap, singleMap ], '*', [ dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value ]); + testAllValues([multiMap, singleMap], '*', [dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value]); }); }); @@ -112,7 +126,7 @@ describe('Metadata', () => { testFirst(singleMap, '*', dcTitle0); }); describe('with [ multiMap, singleMap ]', () => { - testFirst([ multiMap, singleMap ], '*', dcDescription); + testFirst([multiMap, singleMap], '*', dcDescription); }); }); @@ -128,7 +142,7 @@ describe('Metadata', () => { testFirstValue(singleMap, '*', dcTitle0.value); }); describe('with [ multiMap, singleMap ]', () => { - testFirstValue([ multiMap, singleMap ], '*', dcDescription.value); + testFirstValue([multiMap, singleMap], '*', dcDescription.value); }); }); @@ -145,7 +159,7 @@ describe('Metadata', () => { testHas(singleMap, '*', false, { value: 'baz' }); }); describe('with [ multiMap, singleMap ]', () => { - testHas([ multiMap, singleMap ], '*', true); + testHas([multiMap, singleMap], '*', true); }); }); @@ -153,7 +167,7 @@ describe('Metadata', () => { const testValueMatches = (value: MetadataValue, expected: boolean, filter?: MetadataValueFilter) => { describe('with value ' + JSON.stringify(value) + ' and filter ' - + (isUndefined(filter) ? 'undefined' : JSON.stringify(filter)), () => { + + (isUndefined(filter) ? 'undefined' : JSON.stringify(filter)), () => { const result = Metadata.valueMatches(value, filter); it('should return ' + expected, () => { expect(result).toEqual(expected); @@ -172,4 +186,32 @@ describe('Metadata', () => { testValueMatches(mdValue('a', 'en_US'), true, { language: 'en_US' }); }); + describe('toViewModelList method', () => { + + const testToViewModelList = (map: MetadataMap, expected: MetadatumViewModel[]) => { + describe('with map ' + JSON.stringify(map), () => { + const result = Metadata.toViewModelList(map); + it('should return ' + JSON.stringify(expected), () => { + expect(result).toEqual(expected); + }); + }); + }; + + testToViewModelList(multiMap, multiViewModelList); + }); + + describe('toMetadataMap method', () => { + + const testToMetadataMap = (metadatumList: MetadatumViewModel[], expected: MetadataMap) => { + describe('with metadatum list ' + JSON.stringify(metadatumList), () => { + const result = Metadata.toMetadataMap(metadatumList); + it('should return ' + JSON.stringify(expected), () => { + expect(result).toEqual(expected); + }); + }); + }; + + testToMetadataMap(multiViewModelList, multiMap); + }); + }); diff --git a/src/app/core/shared/metadata.model.ts b/src/app/core/shared/metadata.utils.ts similarity index 62% rename from src/app/core/shared/metadata.model.ts rename to src/app/core/shared/metadata.utils.ts index 2b29659252..938d646a82 100644 --- a/src/app/core/shared/metadata.model.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,5 +1,11 @@ import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; -import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces'; +import { + MetadataMapInterface, + MetadataValue, + MetadataValueFilter, + MetadatumViewModel +} from './metadata.models'; +import { groupBy, sortBy } from 'lodash'; /** * Utility class for working with DSpace object metadata. @@ -19,23 +25,23 @@ 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 + * @param {MetadataMapInterface|MetadataMapInterface[]} 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[], + public static all(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): MetadataValue[] { - const mdMaps: MetadataMap[] = mapOrMaps instanceof Array ? mapOrMaps : [ mapOrMaps ]; + const mdMaps: MetadataMapInterface[] = 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 (Metadata.valueMatches(candidate as MetadataValue, filter)) { + matches.push(candidate as MetadataValue); } } } @@ -50,13 +56,13 @@ export class Metadata { /** * Like [[Metadata.all]], but only returns string values. * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). When multiple maps are given, they will be + * @param {MetadataMapInterface|MetadataMapInterface[]} 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[], + public static allValues(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): string[] { return Metadata.all(mapOrMaps, keyOrKeys, filter).map((mdValue) => mdValue.value); } @@ -64,17 +70,17 @@ export class Metadata { /** * Gets the first matching MetadataValue object in the map(s), or `undefined`. * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {MetadataMapInterface|MetadataMapInterface[]} 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[], + public static first(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): MetadataValue { - const mdMaps: MetadataMap[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [ mdMapOrMaps ]; + const mdMaps: MetadataMapInterface[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [mdMapOrMaps]; for (const mdMap of mdMaps) { for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) { - const values: MetadataValue[] = mdMap[key]; + const values: MetadataValue[] = mdMap[key] as MetadataValue[]; if (values) { return values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); } @@ -85,12 +91,12 @@ export class Metadata { /** * Like [[Metadata.first]], but only returns a string value, or `undefined`. * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {MetadataMapInterface|MetadataMapInterface[]} 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[], + public static firstValue(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): string { const value = Metadata.first(mdMapOrMaps, keyOrKeys, filter); return isUndefined(value) ? undefined : value.value; @@ -99,12 +105,12 @@ export class Metadata { /** * Checks for a matching metadata value in the given map(s). * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {MetadataMapInterface|MetadataMapInterface[]} 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[], + public static has(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): boolean { return isNotUndefined(Metadata.first(mdMapOrMaps, keyOrKeys, filter)); } @@ -140,11 +146,11 @@ export class Metadata { /** * Gets the list of keys in the map limited by, and in the order given by `keyOrKeys`. * - * @param {MetadataMap} mdMap The source map. + * @param {MetadataMapInterface} 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 ]; + private static resolveKeys(mdMap: MetadataMapInterface = {}, keyOrKeys: string | string[]): string[] { + const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; const outputKeys: string[] = []; for (const inputKey of inputKeys) { if (inputKey.includes('*')) { @@ -160,4 +166,53 @@ export class Metadata { } return outputKeys; } + + /** + * Creates an array of MetadatumViewModels from an existing MetadataMapInterface. + * + * @param {MetadataMapInterface} mdMap The source map. + * @returns {MetadatumViewModel[]} List of metadata view models based on the source map. + */ + public static toViewModelList(mdMap: MetadataMapInterface): MetadatumViewModel[] { + let metadatumList: MetadatumViewModel[] = []; + Object.keys(mdMap) + .sort() + .forEach((key: string) => { + const fields = mdMap[key].map( + (metadataValue: MetadataValue, index: number) => + Object.assign( + {}, + metadataValue, + { + order: index, + key + })); + metadatumList = [...metadatumList, ...fields]; + }); + return metadatumList; + } + + /** + * Creates an MetadataMapInterface from an existing array of MetadatumViewModels. + * + * @param {MetadatumViewModel[]} viewModelList The source list. + * @returns {MetadataMapInterface} Map with metadata values based on the source list. + */ + public static toMetadataMap(viewModelList: MetadatumViewModel[]): MetadataMapInterface { + const metadataMap: MetadataMapInterface = {}; + const groupedList = groupBy(viewModelList, (viewModel) => viewModel.key); + Object.keys(groupedList) + .sort() + .forEach((key: string) => { + const orderedValues = sortBy(groupedList[key], ['order']); + metadataMap[key] = orderedValues.map((value: MetadataValue) => { + const val = Object.assign({}, value); + delete (val as any).order; + delete (val as any).key; + return val; + } + ) + }); + return metadataMap; + } } diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 0f426ba2c3..2eb47507b2 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -64,7 +64,7 @@ describe('Core Module - RxJS Operators', () => { scheduler.schedule(() => source.pipe(getRequestFromRequestHref(requestService)).subscribe()); scheduler.flush(); - expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref) + expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref); }); it('shouldn\'t return anything if there is no request matching the self link', () => { @@ -159,6 +159,22 @@ describe('Core Module - RxJS Operators', () => { }); }); + describe('getResponseFromEntry', () => { + it('should return the response for all not empty request entries, when they have a value', () => { + const source = hot('abcdefg', testRCEs); + const result = source.pipe(getResponseFromEntry()); + const expected = cold('abcde--', { + a: testRCEs.a.response, + b: testRCEs.b.response, + c: testRCEs.c.response, + d: testRCEs.d.response, + e: testRCEs.e.response + }); + + expect(result).toBeObservable(expected) + }); + }); + describe('getSucceededRemoteData', () => { it('should return the first() hasSucceeded RemoteData Observable', () => { const testRD = { diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 9a429df5e1..ce9740a0fc 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -60,7 +60,7 @@ export const getRemoteDataPayload = () => export const getSucceededRemoteData = () => (source: Observable>): Observable> => - source.pipe(find((rd: RemoteData) => rd.hasSucceeded), hasValueOperator()); + source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); export const getFinishedRemoteData = () => (source: Observable>): Observable> => @@ -91,7 +91,7 @@ export const getBrowseDefinitionLinks = (definitionID: string) => source.pipe( getRemoteDataPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions - .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) + .find((def: BrowseDefinition) => def.id === definitionID) ), map((def: BrowseDefinition) => { if (isNotEmpty(def)) { @@ -101,3 +101,12 @@ export const getBrowseDefinitionLinks = (definitionID: string) => } }) ); + +/** + * Get the first occurrence of an object within a paginated list + */ +export const getFirstOccurrence = () => + (source: Observable>>): Observable> => + source.pipe( + map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined })) + ); diff --git a/src/app/core/submission/models/normalized-edititem.model.ts b/src/app/core/submission/models/normalized-edititem.model.ts index cb30eead62..5615512399 100644 --- a/src/app/core/submission/models/normalized-edititem.model.ts +++ b/src/app/core/submission/models/normalized-edititem.model.ts @@ -5,6 +5,6 @@ import { EditItem } from './edititem.model'; @mapsTo(EditItem) @inheritSerialization(NormalizedSubmissionObject) -export class NormalizedEditItem extends NormalizedSubmissionObject { +export class NormalizedEditItem extends NormalizedSubmissionObject { } diff --git a/src/app/core/submission/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts index 013ec742e8..8091781760 100644 --- a/src/app/core/submission/models/normalized-submission-object.model.ts +++ b/src/app/core/submission/models/normalized-submission-object.model.ts @@ -1,13 +1,15 @@ import { autoserialize, inheritSerialization } from 'cerialize'; + import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; import { SubmissionObjectError } from './submission-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; /** * An abstract model class for a NormalizedSubmissionObject. */ @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedSubmissionObject extends NormalizedDSpaceObject { +export class NormalizedSubmissionObject extends NormalizedDSpaceObject { /** * The workspaceitem/workflowitem identifier diff --git a/src/app/core/submission/models/normalized-workflowitem.model.ts b/src/app/core/submission/models/normalized-workflowitem.model.ts index 12d33f06c6..0ea4ff6150 100644 --- a/src/app/core/submission/models/normalized-workflowitem.model.ts +++ b/src/app/core/submission/models/normalized-workflowitem.model.ts @@ -7,7 +7,7 @@ import { ResourceType } from '../../shared/resource-type'; @mapsTo(Workflowitem) @inheritSerialization(NormalizedSubmissionObject) -export class NormalizedWorkflowItem extends NormalizedSubmissionObject { +export class NormalizedWorkflowItem extends NormalizedSubmissionObject { @autoserialize @relationship(ResourceType.Collection, false) diff --git a/src/app/core/submission/models/normalized-workspaceitem.model.ts b/src/app/core/submission/models/normalized-workspaceitem.model.ts index 56a956878b..7ec40d6524 100644 --- a/src/app/core/submission/models/normalized-workspaceitem.model.ts +++ b/src/app/core/submission/models/normalized-workspaceitem.model.ts @@ -5,11 +5,12 @@ import { NormalizedSubmissionObject } from './normalized-submission-object.model import { mapsTo, relationship } from '../../cache/builders/build-decorators'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; import { ResourceType } from '../../shared/resource-type'; +import { Workflowitem } from './workflowitem.model'; @mapsTo(Workspaceitem) @inheritSerialization(NormalizedDSpaceObject) @inheritSerialization(NormalizedSubmissionObject) -export class NormalizedWorkspaceItem extends NormalizedSubmissionObject { +export class NormalizedWorkspaceItem extends NormalizedSubmissionObject { @autoserialize @relationship(ResourceType.Collection, false) diff --git a/src/app/core/submission/models/workspaceitem-section-form.model.ts b/src/app/core/submission/models/workspaceitem-section-form.model.ts index 116d34f1cc..cfae3f5b0f 100644 --- a/src/app/core/submission/models/workspaceitem-section-form.model.ts +++ b/src/app/core/submission/models/workspaceitem-section-form.model.ts @@ -1,6 +1,6 @@ import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { MetadataMap } from '../../shared/metadata.interfaces'; +import { MetadataMapInterface } from '../../shared/metadata.models'; -export interface WorkspaceitemSectionFormObject extends MetadataMap { +export interface WorkspaceitemSectionFormObject extends MetadataMapInterface { [metadata: string]: FormFieldMetadataValueObject[]; } diff --git a/src/app/core/submission/normalized-submission-object-factory.ts b/src/app/core/submission/normalized-submission-object-factory.ts deleted file mode 100644 index 31cfc074c9..0000000000 --- a/src/app/core/submission/normalized-submission-object-factory.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { SubmissionDefinitionsModel } from '../config/models/config-submission-definitions.model'; -import { SubmissionFormsModel } from '../config/models/config-submission-forms.model'; -import { SubmissionSectionModel } from '../config/models/config-submission-section.model'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { NormalizedBitstream } from '../cache/models/normalized-bitstream.model'; -import { NormalizedBundle } from '../cache/models/normalized-bundle.model'; -import { NormalizedCollection } from '../cache/models/normalized-collection.model'; -import { NormalizedCommunity } from '../cache/models/normalized-community.model'; -import { NormalizedItem } from '../cache/models/normalized-item.model'; -import { NormalizedLicense } from '../cache/models/normalized-license.model'; -import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { ConfigObject } from '../config/models/config.model'; -import { SubmissionResourceType } from './submission-resource-type'; -import { NormalizedResourcePolicy } from '../cache/models/normalized-resource-policy.model'; -import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; -import { NormalizedEditItem } from './models/normalized-edititem.model'; -import { ResourceType } from '../shared/resource-type'; -import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; -import { NormalizedGroup } from '../eperson/models/normalized-group.model'; - -export class NormalizedSubmissionObjectFactory { - public static getConstructor(type: SubmissionResourceType): GenericConstructor { - switch (type) { - case SubmissionResourceType.Bitstream: { - return NormalizedBitstream - } - case SubmissionResourceType.Bundle: { - return NormalizedBundle - } - case SubmissionResourceType.Item: { - return NormalizedItem - } - case SubmissionResourceType.Collection: { - return NormalizedCollection - } - case SubmissionResourceType.Community: { - return NormalizedCommunity - } - case SubmissionResourceType.ResourcePolicy: { - return NormalizedResourcePolicy - } - case SubmissionResourceType.License: { - return NormalizedLicense - } - case SubmissionResourceType.EPerson: { - return NormalizedEPerson - } - case SubmissionResourceType.Group: { - return NormalizedGroup - } - case SubmissionResourceType.WorkspaceItem: { - return NormalizedWorkspaceItem - } - case SubmissionResourceType.WorkflowItem: { - return NormalizedWorkflowItem - } - case SubmissionResourceType.EditItem: { - return NormalizedEditItem - } - case SubmissionResourceType.SubmissionDefinition: - case SubmissionResourceType.SubmissionDefinitions: { - return SubmissionDefinitionsModel - } - case SubmissionResourceType.SubmissionForm: - case SubmissionResourceType.SubmissionForms: { - return SubmissionFormsModel - } - case SubmissionResourceType.SubmissionSection: - case SubmissionResourceType.SubmissionSections: { - return SubmissionSectionModel - } - default: { - return undefined; - } - } - } -} diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index c9c944d74d..abce34808e 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -10,13 +10,13 @@ import { BaseResponseParsingService } from '../data/base-response-parsing.servic import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NormalizedSubmissionObjectFactory } from './normalized-submission-object-factory'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { SubmissionResourceType } from './submission-resource-type'; import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; import { NormalizedEditItem } from './models/normalized-edititem.model'; import { FormFieldMetadataValueObject } from '../../shared/form/builder/models/form-field-metadata-value.model'; +import { SubmissionObject } from './models/submission-object.model'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; export function isServerFormValue(obj: any): boolean { return (typeof obj === 'object' @@ -66,7 +66,7 @@ export function normalizeSectionData(obj: any) { @Injectable() export class SubmissionResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { - protected objectFactory = NormalizedSubmissionObjectFactory; + protected objectFactory = NormalizedObjectFactory; protected toCache = false; constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @@ -78,7 +78,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 201 || data.statusCode === 200)) { - const dataDefinition = this.processResponse(data.payload, request.href); + const dataDefinition = this.processResponse(data.payload, request.href); return new SubmissionSuccessResponse(dataDefinition, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else if (isEmpty(data.payload) && data.statusCode === 204) { // Response from a DELETE request @@ -94,7 +94,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService } protected processResponse(data: any, requestHref: string): any[] { - const dataDefinition = this.process(data, requestHref); + const dataDefinition = this.process(data, requestHref); const normalizedDefinition = Array.of(); const processedList = Array.isArray(dataDefinition) ? dataDefinition : Array.of(dataDefinition); diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 4468ffdf04..266d2b5411 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -6,7 +6,6 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; -import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; import { Workflowitem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from '../data/request.models'; @@ -16,12 +15,12 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @Injectable() -export class WorkflowitemDataService extends DataService { +export class WorkflowitemDataService extends DataService { protected linkPath = 'workflowitems'; protected forceBypassCache = true; constructor( - protected comparator: DSOChangeAnalyzer, + protected comparator: DSOChangeAnalyzer, protected dataBuildService: NormalizedObjectBuildService, protected halService: HALEndpointService, protected http: HttpClient, diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 2edd086fd0..119bfb66cc 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -7,7 +7,6 @@ import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { Workspaceitem } from './models/workspaceitem.model'; -import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from '../data/request.models'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; @@ -16,12 +15,12 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @Injectable() -export class WorkspaceitemDataService extends DataService { +export class WorkspaceitemDataService extends DataService { protected linkPath = 'workspaceitems'; protected forceBypassCache = true; constructor( - protected comparator: DSOChangeAnalyzer, + protected comparator: DSOChangeAnalyzer, protected dataBuildService: NormalizedObjectBuildService, protected halService: HALEndpointService, protected http: HttpClient, diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index df6226aedc..008a86599d 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -73,6 +73,17 @@ export class NavbarComponent extends MenuComponent implements OnInit { link: '/browse/title' } 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: '/browse/dateissued' + } as LinkMenuItemModel, + }, { id: 'browse_global_by_author', parentID: 'browse_global', diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index fc3300ae72..bad9f3fe8c 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -1,11 +1,38 @@

{{title | translate}}

+
- - +
+ + +
+
+
+
+
+ +
+ + + + +
+
+
+
+
    +
  • + +
  • +
+
+ + +
+
diff --git a/src/app/shared/browse-by/browse-by.component.scss b/src/app/shared/browse-by/browse-by.component.scss index e69de29bb2..5d847a8609 100644 --- a/src/app/shared/browse-by/browse-by.component.scss +++ b/src/app/shared/browse-by/browse-by.component.scss @@ -0,0 +1,8 @@ +:host { + .dropdown-toggle::after { + display: none; + } + .dropdown-item { + padding-left: 20px; + } +} diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 2417dde7ca..bae345d009 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -1,19 +1,68 @@ import { BrowseByComponent } from './browse-by.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { SharedModule } from '../shared.module'; +import { CommonModule } from '@angular/common'; +import { Item } from '../../core/shared/item.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { StoreModule } from '@ngrx/store'; +import { MockTranslateLoader } from '../mocks/mock-translate-loader'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; describe('BrowseByComponent', () => { let comp: BrowseByComponent; let fixture: ComponentFixture; + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId-1', + metadata: [ + { + key: 'dc.title', + value: 'First Fake Title' + } + ] + }), + Object.assign(new Item(), { + id: 'fakeId-2', + metadata: [ + { + key: 'dc.title', + value: 'Second Fake Title' + } + ] + }) + ]; + const mockItemsRD$ = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), mockItems))); + beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule], + imports: [ + CommonModule, + TranslateModule.forRoot(), + SharedModule, + NgbModule.forRoot(), + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + RouterTestingModule, + BrowserAnimationsModule + ], declarations: [], + providers: [], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); @@ -41,4 +90,67 @@ describe('BrowseByComponent', () => { expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).toBeDefined(); }); + describe('when enableArrows is true and objects are defined', () => { + beforeEach(() => { + comp.enableArrows = true; + comp.objects$ = mockItemsRD$; + comp.paginationConfig = Object.assign(new PaginationComponentOptions(), { + id: 'test-pagination', + currentPage: 1, + pageSizeOptions: [5,10,15,20], + pageSize: 15 + }); + comp.sortConfig = Object.assign(new SortOptions('dc.title', SortDirection.ASC)); + fixture.detectChanges(); + }); + + describe('when clicking the previous arrow button', () => { + beforeEach(() => { + spyOn(comp.prev, 'emit'); + fixture.debugElement.query(By.css('#nav-prev')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.prev.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking the next arrow button', () => { + beforeEach(() => { + spyOn(comp.next, 'emit'); + fixture.debugElement.query(By.css('#nav-next')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.next.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different page size', () => { + beforeEach(() => { + spyOn(comp.pageSizeChange, 'emit'); + fixture.debugElement.query(By.css('.page-size-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.pageSizeChange.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different sort direction', () => { + beforeEach(() => { + spyOn(comp.sortDirectionChange, 'emit'); + fixture.debugElement.query(By.css('.sort-direction-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.sortDirectionChange.emit).toHaveBeenCalled(); + }); + }); + }); + }); diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 2e9e825a6b..6c4bc78213 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -1,11 +1,12 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, OnInit, Output } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; -import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { fadeIn, fadeInOut } from '../animations/fade'; import { Observable } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; +import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by', @@ -19,7 +20,7 @@ import { ListableObject } from '../object-collection/shared/listable-object.mode /** * Component to display a browse-by page for any ListableObject */ -export class BrowseByComponent { +export class BrowseByComponent implements OnInit { /** * The i18n message to display as title */ @@ -39,4 +40,106 @@ export class BrowseByComponent { * The sorting configuration used for the list */ @Input() sortConfig: SortOptions; + + /** + * The type of StartsWith options used to define what component to render for the options + * Defaults to text + */ + @Input() type = StartsWithType.text; + + /** + * The list of options to render for the StartsWith component + */ + @Input() startsWithOptions = []; + + /** + * Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination + */ + @Input() enableArrows = false; + + /** + * If enableArrows is set to true, should it hide the options gear? + */ + @Input() hideGear = false; + + /** + * If enableArrows is set to true, emit when the previous button is clicked + */ + @Output() prev = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the next button is clicked + */ + @Output() next = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the page size is changed + */ + @Output() pageSizeChange = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the sort direction is changed + */ + @Output() sortDirectionChange = new EventEmitter(); + + /** + * An object injector used to inject the startsWithOptions to the switchable StartsWith component + */ + objectInjector: Injector; + + /** + * Declare SortDirection enumeration to use it in the template + */ + public sortDirections = SortDirection; + + public constructor(private injector: Injector) { + + } + + /** + * Go to the previous page + */ + goPrev() { + this.prev.emit(true); + } + + /** + * Go to the next page + */ + goNext() { + this.next.emit(true); + } + + /** + * Change the page size + * @param size + */ + doPageSizeChange(size) { + this.paginationConfig.pageSize = size; + this.pageSizeChange.emit(size); + } + + /** + * Change the sort direction + * @param direction + */ + doSortDirectionChange(direction) { + this.sortConfig.direction = direction; + this.sortDirectionChange.emit(direction); + } + + /** + * Get the switchable StartsWith component dependant on the type + */ + getStartsWithComponent() { + return getStartsWithComponent(this.type); + } + + ngOnInit(): void { + this.objectInjector = Injector.create({ + providers: [{ provide: 'startsWithOptions', useFactory: () => (this.startsWithOptions), deps:[] }], + parent: this.injector + }); + } + } diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html index 720ad0c1cf..6c67937063 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html @@ -1,3 +1,3 @@ + [formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"> 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 cc1e2063ff..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 @@ -50,10 +50,7 @@ describe('ComColFormComponent', () => { ]; /* tslint:disable:no-empty */ - const locationStub = { - back: () => { - } - }; + const locationStub = jasmine.createSpyObj('location', ['back']); /* tslint:enable:no-empty */ beforeEach(async(() => { @@ -112,4 +109,11 @@ describe('ComColFormComponent', () => { ); }) }); + + describe('onCancel', () => { + it('should call the back method on the Location service', () => { + comp.onCancel(); + expect(locationStub.back).toHaveBeenCalled(); + }); + }); }); 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 0e0195aaaa..e24676a646 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,7 +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 { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models'; import { isNotEmpty } from '../../empty.util'; import { ResourceType } from '../../../core/shared/resource-type'; @@ -83,11 +83,14 @@ export class ComColFormComponent implements OnInit { onSubmit() { const formMetadata = new Object() as MetadataMap; this.formModel.forEach((fieldModel: DynamicInputModel) => { - const value: MetadataValue = { value: fieldModel.value as string, language: null }; + const value: MetadataValue = { + value: fieldModel.value as string, + language: null + } as any; if (formMetadata.hasOwnProperty(fieldModel.name)) { formMetadata[fieldModel.name].push(value); } else { - formMetadata[fieldModel.name] = [ value ]; + formMetadata[fieldModel.name] = [value]; } }); @@ -117,4 +120,8 @@ export class ComColFormComponent implements OnInit { } ); } + + onCancel() { + this.location.back(); + } } diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts index fd3464ba5e..4dad4a703f 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts @@ -11,13 +11,12 @@ import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { CreateComColPageComponent } from './create-comcol-page.component'; import { DataService } from '../../../core/data/data.service'; describe('CreateComColPageComponent', () => { - let comp: CreateComColPageComponent; - let fixture: ComponentFixture>; + let comp: CreateComColPageComponent; + let fixture: ComponentFixture>; let communityDataService: CommunityDataService; let dsoDataService: CommunityDataService; let routeService: RouteService; diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index fc7ee3ee70..c9fcfecb97 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -10,7 +10,6 @@ import { take } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DataService } from '../../../core/data/data.service'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; /** * Component representing the create page for communities and collections @@ -19,7 +18,7 @@ import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-ds selector: 'ds-create-comcol', template: '' }) -export class CreateComColPageComponent implements OnInit { +export class CreateComColPageComponent implements OnInit { /** * Frontend endpoint for this type of DSO */ @@ -36,7 +35,7 @@ export class CreateComColPageComponent>; public constructor( - protected dsoDataService: DataService, + protected dsoDataService: DataService, protected parentDataService: CommunityDataService, protected routeService: RouteService, protected router: Router diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts index c1444ec25b..3b39d36008 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts @@ -9,15 +9,14 @@ import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { DataService } from '../../../core/data/data.service'; import { DeleteComColPageComponent } from './delete-comcol-page.component'; import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; describe('DeleteComColPageComponent', () => { - let comp: DeleteComColPageComponent; - let fixture: ComponentFixture>; + let comp: DeleteComColPageComponent; + let fixture: ComponentFixture>; let dsoDataService: CommunityDataService; let router: Router; diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts index 6e3a826e87..e2e73bae14 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts @@ -1,13 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; -import { RouteService } from '../../services/route.service'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; -import { isNotUndefined } from '../../empty.util'; import { first, map } from 'rxjs/operators'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DataService } from '../../../core/data/data.service'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { NotificationsService } from '../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; @@ -19,7 +15,7 @@ import { TranslateService } from '@ngx-translate/core'; selector: 'ds-delete-comcol', template: '' }) -export class DeleteComColPageComponent implements OnInit { +export class DeleteComColPageComponent implements OnInit { /** * Frontend endpoint for this type of DSO */ @@ -30,7 +26,7 @@ export class DeleteComColPageComponent>; public constructor( - protected dsoDataService: DataService, + protected dsoDataService: DataService, protected router: Router, protected route: ActivatedRoute, protected notifications: NotificationsService, diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts index 88c11a0b4d..75b7fe40e7 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts @@ -10,13 +10,12 @@ import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { EditComColPageComponent } from './edit-comcol-page.component'; import { DataService } from '../../../core/data/data.service'; describe('EditComColPageComponent', () => { - let comp: EditComColPageComponent; - let fixture: ComponentFixture>; + let comp: EditComColPageComponent; + let fixture: ComponentFixture>; let dsoDataService: CommunityDataService; let router: Router; diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts index b669fcea54..24181b5e61 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts @@ -6,7 +6,6 @@ import { isNotUndefined } from '../../empty.util'; import { first, map } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DataService } from '../../../core/data/data.service'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; /** @@ -16,7 +15,7 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; selector: 'ds-edit-comcol', template: '' }) -export class EditComColPageComponent implements OnInit { +export class EditComColPageComponent implements OnInit { /** * Frontend endpoint for this type of DSO */ @@ -27,7 +26,7 @@ export class EditComColPageComponent>; public constructor( - protected dsoDataService: DataService, + protected dsoDataService: DataService, protected router: Router, protected route: ActivatedRoute ) { 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 index 653bd1ed53..f9ef4e5232 100644 --- 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 @@ -1,6 +1,7 @@

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

diff --git a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts index fdf4ce1f5d..45489e3618 100644 --- a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts +++ b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts @@ -1,13 +1,13 @@ import { isEmpty, isNotEmpty, isNotNull } from '../../../empty.util'; import { ConfidenceType } from '../../../../core/integration/models/confidence-type'; import { PLACEHOLDER_PARENT_METADATA } from '../ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; -import { MetadataValue } from '../../../../core/shared/metadata.interfaces'; +import { MetadataValueInterface } from '../../../../core/shared/metadata.models'; export interface OtherInformation { [name: string]: string } -export class FormFieldMetadataValueObject implements MetadataValue { +export class FormFieldMetadataValueObject implements MetadataValueInterface { metadata?: string; value: any; display: string; diff --git a/src/app/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html index bbe090dac0..49e4c0a1d5 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.html +++ b/src/app/shared/input-suggestions/input-suggestions.component.html @@ -1,21 +1,22 @@ -
- + +
\ No newline at end of file diff --git a/src/app/shared/input-suggestions/input-suggestions.component.scss b/src/app/shared/input-suggestions/input-suggestions.component.scss index bea74cf7af..f2587e1b6f 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.scss +++ b/src/app/shared/input-suggestions/input-suggestions.component.scss @@ -1,8 +1,11 @@ +@import "../../../styles/_variables.scss"; + .autocomplete { width: 100%; .dropdown-item { white-space: normal; word-break: break-word; + padding: $input-padding-y $input-padding-x; &:focus { outline: none; } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts index 8b6cdd2aa5..1f16a84b2c 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.spec.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts @@ -77,7 +77,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the last element ', () => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(lastLink.nativeElement); }); @@ -103,7 +103,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the first element ', () => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(firstLink.nativeElement); }); @@ -117,7 +117,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second element', () => { - const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a')); + const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLink.nativeElement); }); @@ -126,7 +126,7 @@ describe('InputSuggestionsComponent', () => { describe('when the first element is in focus', () => { beforeEach(() => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); firstLink.nativeElement.focus(); comp.selectedIndex = 0; fixture.detectChanges(); @@ -140,7 +140,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the last element ', () => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(lastLink.nativeElement); }); @@ -153,7 +153,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second element ', () => { - const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a')); + const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLink.nativeElement); }); @@ -162,7 +162,7 @@ describe('InputSuggestionsComponent', () => { describe('when the last element is in focus', () => { beforeEach(() => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); lastLink.nativeElement.focus(); comp.selectedIndex = suggestions.length - 1; fixture.detectChanges(); @@ -176,7 +176,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second last element ', () => { - const secondLastLink = de.query(By.css('.list-unstyled > li:nth-last-child(2) a')); + const secondLastLink = de.query(By.css('.dropdown-list > div:nth-last-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLastLink.nativeElement); }); @@ -189,7 +189,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the first element ', () => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(firstLink.nativeElement); }); @@ -294,7 +294,7 @@ describe('InputSuggestionsComponent', () => { const clickedIndex = 0; beforeEach(() => { spyOn(comp, 'onClickSuggestion'); - const clickedLink = de.query(By.css('.list-unstyled > li:nth-child(' + (clickedIndex + 1) + ') a')); + const clickedLink = de.query(By.css('.dropdown-list > div:nth-child(' + (clickedIndex + 1) + ') a')); clickedLink.triggerEventHandler('click', {} ); fixture.detectChanges(); }); diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 8a8069d71e..727421c83e 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -1,29 +1,44 @@ import { Component, - ElementRef, EventEmitter, + ElementRef, + EventEmitter, + forwardRef, Input, + OnChanges, Output, - QueryList, SimpleChanges, + QueryList, + SimpleChanges, ViewChild, ViewChildren } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { hasValue, isNotEmpty } from '../empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../empty.util'; +import { InputSuggestion } from './input-suggestions.model'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ selector: 'ds-input-suggestions', styleUrls: ['./input-suggestions.component.scss'], - templateUrl: './input-suggestions.component.html' + templateUrl: './input-suggestions.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => InputSuggestionsComponent), + multi: true + } + ] }) /** * Component representing a form with a autocomplete functionality */ -export class InputSuggestionsComponent { +export class InputSuggestionsComponent implements ControlValueAccessor, OnChanges { /** * The suggestions that should be shown */ - @Input() suggestions: any[] = []; + @Input() suggestions: InputSuggestion[] = []; /** * The time waited to detect if any other input will follow before requesting the suggestions @@ -46,14 +61,9 @@ export class InputSuggestionsComponent { @Input() name; /** - * Value of the input field + * Whether or not the current input is valid */ - @Input() ngModel; - - /** - * Output for when the input field's value changes - */ - @Output() ngModelChange = new EventEmitter(); + @Input() valid = true; /** * Output for when the form is submitted @@ -65,6 +75,11 @@ export class InputSuggestionsComponent { */ @Output() clickSuggestion = new EventEmitter(); + /** + * Output for when something is typed in the input field + */ + @Output() typeSuggestion = new EventEmitter(); + /** * Output for when new suggestions should be requested */ @@ -94,12 +109,26 @@ export class InputSuggestionsComponent { */ @ViewChildren('suggestion') resultViews: QueryList; + /** + * Value of the input field + */ + _value: string; + + /** Fields needed to add ngModel */ + @Input() disabled = false; + propagateChange = (_: any) => { + /* Empty implementation */ + }; + propagateTouch = (_: any) => { + /* Empty implementation */ + }; + /** * When any of the inputs change, check if we should still show the suggestions */ ngOnChanges(changes: SimpleChanges) { if (hasValue(changes.suggestions)) { - this.show.next(isNotEmpty(changes.suggestions.currentValue)); + this.show.next(isNotEmpty(changes.suggestions.currentValue) && !changes.suggestions.firstChange); } } @@ -170,6 +199,7 @@ export class InputSuggestionsComponent { * Make sure that if a suggestion is clicked, the suggestions dropdown closes, does not reopen and the focus moves to the input field */ onClickSuggestion(data) { + this.value = data; this.clickSuggestion.emit(data); this.close(); this.blockReopen = true; @@ -184,8 +214,40 @@ export class InputSuggestionsComponent { find(data) { if (!this.blockReopen) { this.findSuggestions.emit(data); + this.typeSuggestion.emit(data); } this.blockReopen = false; } + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + /* START - Method's needed to add ngModel (ControlValueAccessor) to a component */ + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + this.propagateTouch = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + this.value = value; + } + + get value() { + return this._value; + } + + set value(val) { + this._value = val; + this.propagateChange(this._value); + } + /* END - Method's needed to add ngModel to a component */ } diff --git a/src/app/shared/input-suggestions/input-suggestions.model.ts b/src/app/shared/input-suggestions/input-suggestions.model.ts new file mode 100644 index 0000000000..1ccdbbe566 --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.model.ts @@ -0,0 +1,14 @@ +/** + * Interface representing a single suggestion for the input suggestions component + */ +export interface InputSuggestion { + /** + * The displayed value of the suggestion + */ + displayValue: string, + + /** + * The actual value of the suggestion + */ + value: string +} diff --git a/src/app/shared/notifications/notifications.service.ts b/src/app/shared/notifications/notifications.service.ts index b8a10da1c7..85c9d26dfb 100644 --- a/src/app/shared/notifications/notifications.service.ts +++ b/src/app/shared/notifications/notifications.service.ts @@ -29,27 +29,31 @@ export class NotificationsService { success(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Success, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Success, title, content, notificationOptions, html); this.add(notification); return notification; } error(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Error, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Error, title, content, notificationOptions, html); this.add(notification); return notification; } info(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Info, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + console.log(notificationOptions); + const notification = new Notification(uniqueId(), NotificationType.Info, title, content, notificationOptions, html); this.add(notification); return notification; } @@ -58,7 +62,8 @@ export class NotificationsService { content: any = observableOf(''), options: NotificationOptions = this.getDefaultOptions(), html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, notificationOptions, html); this.add(notification); return notification; } 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 0901b7b8cc..844d0bc165 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 @@ -6,7 +6,7 @@ import { AbstractListableElementComponent } from '../../object-collection/shared 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'; +import { Metadata } from '../../../core/shared/metadata.utils'; @Component({ selector: 'ds-search-result-grid-element', 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 6139e4a9df..0fad726777 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/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 2a16b0b754..525d39e798 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 @@ -6,7 +6,7 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; 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'; +import { Metadata } from '../../../core/shared/metadata.utils'; @Component({ selector: 'ds-search-result-list-element', diff --git a/src/app/shared/pagination/pagination-component-options.model.ts b/src/app/shared/pagination/pagination-component-options.model.ts index 07756f1e6f..4f8a3c5c8c 100644 --- a/src/app/shared/pagination/pagination-component-options.model.ts +++ b/src/app/shared/pagination/pagination-component-options.model.ts @@ -20,7 +20,7 @@ export class PaginationComponentOptions extends NgbPaginationConfig { /** * A number array that represents options for a context pagination limit. */ - pageSizeOptions: number[] = [5, 10, 20, 40, 60, 80, 100]; + pageSizeOptions: number[] = [1, 5, 10, 20, 40, 60, 80, 100]; /** * Number of items per page. diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts index 0f2907a7bf..825365c8c1 100644 --- a/src/app/shared/pagination/pagination.component.ts +++ b/src/app/shared/pagination/pagination.component.ts @@ -8,16 +8,19 @@ import { Output, ViewEncapsulation } from '@angular/core' - import { ActivatedRoute, Router } from '@angular/router'; import { Subscription, Observable } from 'rxjs'; +import { isNumeric } from 'rxjs/internal-compatibility'; +import { isEqual, isObject, transform } from 'lodash'; + import { HostWindowService } from '../host-window.service'; import { HostWindowState } from '../host-window.reducer'; import { PaginationComponentOptions } from './pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { hasValue, isNotEmpty } from '../empty.util'; import { PageInfo } from '../../core/shared/page-info.model'; +import { difference } from '../object.util'; /** * The default pagination controls component. @@ -168,7 +171,7 @@ export class PaginationComponent implements OnDestroy, OnInit { this.subs.push(this.route.queryParams .subscribe((queryParams) => { if (this.isEmptyPaginationParams(queryParams)) { - this.initializeConfig(); + this.initializeConfig(queryParams); } else { this.currentQueryParams = queryParams; const fixedProperties = this.validateParams(queryParams); @@ -197,7 +200,7 @@ export class PaginationComponent implements OnDestroy, OnInit { /** * Initializes all default variables */ - private initializeConfig() { + private initializeConfig(queryParams: any = {}) { // Set initial values this.id = this.paginationOptions.id || null; this.pageSizeOptions = this.paginationOptions.pageSizeOptions; @@ -207,13 +210,13 @@ export class PaginationComponent implements OnDestroy, OnInit { this.sortDirection = this.sortOptions.direction; this.sortField = this.sortOptions.field; } - this.currentQueryParams = { + this.currentQueryParams = Object.assign({}, queryParams, { pageId: this.id, page: this.currentPage, pageSize: this.pageSize, sortDirection: this.sortDirection, sortField: this.sortField - }; + }); } /** @@ -235,7 +238,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The page being navigated to. */ public doPageChange(page: number) { - this.updateRoute({ page: page }); + this.updateRoute({ page: page.toString() }); } /** @@ -333,10 +336,23 @@ export class PaginationComponent implements OnDestroy, OnInit { * Method to update the route parameters */ private updateRoute(params: {}) { - this.router.navigate([], { - queryParams: Object.assign({}, this.currentQueryParams, params), - queryParamsHandling: 'merge' - }); + if (isNotEmpty(difference(params, this.currentQueryParams))) { + this.router.navigate([], { + queryParams: Object.assign({}, this.currentQueryParams, params), + queryParamsHandling: 'merge' + }); + } + } + + private difference(object, base) { + const changes = (o, b) => { + return transform(o, (result, value, key) => { + if (!isEqual(value, b[key]) && isNotEmpty(value)) { + result[key] = (isObject(value) && isObject(b[key])) ? changes(value, b[key]) : value; + } + }); + }; + return changes(object, base); } /** @@ -418,7 +434,7 @@ export class PaginationComponent implements OnDestroy, OnInit { */ private validatePage(page: any): number { let result = this.currentPage; - if (!isNaN(page)) { + if (isNumeric(page)) { result = +page; } return result; diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 1acff996fa..43a3a0cd77 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -110,7 +110,12 @@ import { CreateComColPageComponent } from './comcol-forms/create-comcol-page/cre import { EditComColPageComponent } from './comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component'; 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'; +import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component'; +import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component'; import { ItemListPreviewComponent } from './object-list/item-list-preview/item-list-preview.component'; import { ItemPageAuthorFieldComponent } from '../+item-page/simple/field-components/specific-field/author/item-page-author-field.component'; import { ItemPageDateFieldComponent } from '../+item-page/simple/field-components/specific-field/date/item-page-date-field.component'; @@ -158,6 +163,7 @@ const PIPES = [ EmphasizePipe, CapitalizePipe, ObjectKeysPipe, + ObjectValuesPipe, ConsolePipe, ObjNgFor ]; @@ -252,7 +258,9 @@ const ENTRY_COMPONENTS = [ DsDatePickerComponent, DsDynamicFormGroupComponent, DsDynamicFormArrayComponent, - DsDatePickerInlineComponent + DsDatePickerInlineComponent, + StartsWithDateComponent, + StartsWithTextComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ @@ -284,6 +292,8 @@ const DIRECTIVES = [ DebounceDirective, ClickOutsideDirective, AuthorityConfidenceStateDirective, + InListValidator, + AutoFocusDirective, RoleDirective ]; diff --git a/src/app/shared/starts-with/date/starts-with-date.component.html b/src/app/shared/starts-with/date/starts-with-date.component.html new file mode 100644 index 0000000000..3f024c3254 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.html @@ -0,0 +1,34 @@ +
+
+ + {{'browse.startsWith.jump' | translate}} + +
+ +
+
+ +
+
+ + + + +
+
+
diff --git a/src/app/shared/starts-with/date/starts-with-date.component.scss b/src/app/shared/starts-with/date/starts-with-date.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/date/starts-with-date.component.spec.ts b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts new file mode 100644 index 0000000000..10a168ab05 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts @@ -0,0 +1,183 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +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 { ActivatedRoute, Router } from '@angular/router'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { By } from '@angular/platform-browser'; +import { StartsWithDateComponent } from './starts-with-date.component'; +import { ActivatedRouteStub } from '../../testing/active-router-stub'; +import { EnumKeysPipe } from '../../utils/enum-keys-pipe'; +import { RouterStub } from '../../testing/router-stub'; + +describe('StartsWithDateComponent', () => { + let comp: StartsWithDateComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let router: Router; + + const options = [2019, 2018, 2017, 2016, 2015]; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [StartsWithDateComponent, EnumKeysPipe], + providers: [ + { provide: 'startsWithOptions', useValue: options }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Router, useValue: new RouterStub() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StartsWithDateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + router = (comp as any).router; + }); + + it('should create a FormGroup containing a startsWith FormControl', () => { + expect(comp.formData.value.startsWith).toBeDefined(); + }); + + describe('when selecting the first option in the year dropdown', () => { + let select; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select#year-select')).nativeElement; + select.value = select.options[0].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to undefined', () => { + expect(comp.startsWith).toBeUndefined(); + }); + + it('should not add a startsWith query parameter', () => { + route.queryParams.subscribe((params) => { + expect(params.startsWith).toBeUndefined(); + }); + }); + }); + + describe('when selecting the second option in the year dropdown', () => { + let select; + let input; + let expectedValue; + let extras; + + beforeEach(() => { + expectedValue = '' + options[0]; + extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + select = fixture.debugElement.query(By.css('select#year-select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + + describe('and selecting the first option in the month dropdown', () => { + let monthSelect; + + beforeEach(() => { + monthSelect = fixture.debugElement.query(By.css('select#month-select')).nativeElement; + monthSelect.value = monthSelect.options[0].value; + monthSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('and selecting the second option in the month dropdown', () => { + let monthSelect; + + beforeEach(() => { + expectedValue = `${options[0]}-01`; + extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + monthSelect = fixture.debugElement.query(By.css('select#month-select')).nativeElement; + monthSelect.value = monthSelect.options[1].value; + monthSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + }); + + describe('when filling in the input form', () => { + let form; + const expectedValue = '2015'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('form')); + comp.formData.value.startsWith = expectedValue; + form.triggerEventHandler('ngSubmit', null); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + }); + +}); diff --git a/src/app/shared/starts-with/date/starts-with-date.component.ts b/src/app/shared/starts-with/date/starts-with-date.component.ts new file mode 100644 index 0000000000..5f87ce8635 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.ts @@ -0,0 +1,140 @@ +import { Component } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Date". + * The options are rendered in a dropdown with an input field (of type number) next to it. + */ +@Component({ + selector: 'ds-starts-with-date', + styleUrls: ['./starts-with-date.component.scss'], + templateUrl: './starts-with-date.component.html' +}) +@renderStartsWithFor(StartsWithType.date) +export class StartsWithDateComponent extends StartsWithAbstractComponent { + + /** + * A list of options for months to select from + */ + monthOptions: string[]; + + /** + * Currently selected month + */ + startsWithMonth = 'none'; + + /** + * Currently selected year + */ + startsWithYear: number; + + ngOnInit() { + this.monthOptions = [ + 'none', + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december' + ]; + + super.ngOnInit(); + } + + /** + * Set the startsWith by event + * @param event + */ + setStartsWithYearEvent(event: Event) { + this.startsWithYear = +(event.target as HTMLInputElement).value; + this.setStartsWithYearMonth(); + this.setStartsWithParam(); + } + + /** + * Set the startsWithMonth by event + * @param event + */ + setStartsWithMonthEvent(event: Event) { + this.startsWithMonth = (event.target as HTMLInputElement).value; + this.setStartsWithYearMonth(); + this.setStartsWithParam(); + } + + /** + * Get startsWith year combined with month; + * Returned value: "{{year}}-{{month}}" + */ + getStartsWith() { + const month = this.getStartsWithMonth(); + if (month > 0 && hasValue(this.startsWithYear) && this.startsWithYear !== -1) { + let twoDigitMonth = '' + month; + if (month < 10) { + twoDigitMonth = `0${month}`; + } + return `${this.startsWithYear}-${twoDigitMonth}`; + } else { + if (hasValue(this.startsWithYear) && this.startsWithYear > 0) { + return '' + this.startsWithYear; + } else { + return undefined; + } + } + } + + /** + * Set startsWith year combined with month; + */ + setStartsWithYearMonth() { + this.startsWith = this.getStartsWith(); + } + + /** + * Set the startsWith by string + * This method also sets startsWithYear and startsWithMonth correctly depending on the received value + * - When startsWith contains a "-", the first part is considered the year, the second part the month + * - When startsWith doesn't contain a "-", the whole string is expected to be the year + * startsWithMonth will be set depending on the index received after the "-" + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + if (hasValue(startsWith) && startsWith.indexOf('-') > -1) { + const split = startsWith.split('-'); + this.startsWithYear = +split[0]; + const month = +split[1]; + if (month < this.monthOptions.length) { + this.startsWithMonth = this.monthOptions[month]; + } else { + this.startsWithMonth = this.monthOptions[0]; + } + } else { + this.startsWithYear = +startsWith; + } + this.setStartsWithParam(); + } + + /** + * Get startsWithYear as a number; + */ + getStartsWithYear() { + return this.startsWithYear; + } + + /** + * Get startsWithMonth as a number; + */ + getStartsWithMonth() { + return this.monthOptions.indexOf(this.startsWithMonth); + } + +} diff --git a/src/app/shared/starts-with/starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts new file mode 100644 index 0000000000..967ec7a844 --- /dev/null +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -0,0 +1,94 @@ +import { Inject, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { FormControl, FormGroup } from '@angular/forms'; +import { hasValue } from '../empty.util'; + +/** + * An abstract component to render StartsWith options + */ +export class StartsWithAbstractComponent implements OnInit, OnDestroy { + /** + * The currently selected startsWith in string format + */ + startsWith: string; + + /** + * The formdata controlling the StartsWith input + */ + formData: FormGroup; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + public constructor(@Inject('startsWithOptions') public startsWithOptions: any[], + protected route: ActivatedRoute, + protected router: Router) { + } + + ngOnInit(): void { + this.subs.push( + this.route.queryParams.subscribe((params) => { + if (hasValue(params.startsWith)) { + this.setStartsWith(params.startsWith); + } + }) + ); + this.formData = new FormGroup({ + startsWith: new FormControl() + }); + } + + /** + * Get startsWith + */ + getStartsWith(): any { + return this.startsWith; + } + + /** + * Set the startsWith by event + * @param event + */ + setStartsWithEvent(event: Event) { + this.startsWith = (event.target as HTMLInputElement).value; + this.setStartsWithParam(); + } + + /** + * Set the startsWith by string + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + this.setStartsWithParam(); + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '-1') { + this.startsWith = undefined; + } + this.router.navigate([], { + queryParams: Object.assign({ startsWith: this.startsWith }), + queryParamsHandling: 'merge' + }); + } + + /** + * Submit the form data. Called when clicking a submit button on the form. + * @param data + */ + submitForm(data) { + this.startsWith = data.startsWith; + this.setStartsWithParam(); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/shared/starts-with/starts-with-decorator.spec.ts b/src/app/shared/starts-with/starts-with-decorator.spec.ts new file mode 100644 index 0000000000..0ba72d8ac4 --- /dev/null +++ b/src/app/shared/starts-with/starts-with-decorator.spec.ts @@ -0,0 +1,13 @@ +import { renderStartsWithFor, StartsWithType } from './starts-with-decorator'; + +describe('BrowseByStartsWithDecorator', () => { + const textDecorator = renderStartsWithFor(StartsWithType.text); + const dateDecorator = renderStartsWithFor(StartsWithType.date); + it('should have a decorator for both text and date', () => { + expect(textDecorator.length).not.toBeNull(); + expect(dateDecorator.length).not.toBeNull(); + }); + it('should have 2 separate decorators for text and date', () => { + expect(textDecorator).not.toEqual(dateDecorator); + }); +}); diff --git a/src/app/shared/starts-with/starts-with-decorator.ts b/src/app/shared/starts-with/starts-with-decorator.ts new file mode 100644 index 0000000000..7592f00a8b --- /dev/null +++ b/src/app/shared/starts-with/starts-with-decorator.ts @@ -0,0 +1,30 @@ +const startsWithMap = new Map(); + +/** + * An enum that defines the type of StartsWith options + */ +export enum StartsWithType { + text = 'Text', + date = 'Date' +} + +/** + * Fetch a decorator to render a StartsWith component for type + * @param type + */ +export function renderStartsWithFor(type: StartsWithType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + startsWithMap.set(type, objectElement); + }; +} + +/** + * Get the correct component depending on the StartsWith type + * @param type + */ +export function getStartsWithComponent(type: StartsWithType) { + return startsWithMap.get(type); +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.html b/src/app/shared/starts-with/text/starts-with-text.component.html new file mode 100644 index 0000000000..dd7f4de278 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.html @@ -0,0 +1,29 @@ +
+
+
+ +
+
+ +
+
+ + + + +
+
+
diff --git a/src/app/shared/starts-with/text/starts-with-text.component.scss b/src/app/shared/starts-with/text/starts-with-text.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.spec.ts b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts new file mode 100644 index 0000000000..653c7e6196 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts @@ -0,0 +1,178 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +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 '../../utils/enum-keys-pipe'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { StartsWithTextComponent } from './starts-with-text.component'; + +describe('StartsWithTextComponent', () => { + let comp: StartsWithTextComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let router: Router; + + const options = ['0-9', 'A', 'B', 'C']; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [StartsWithTextComponent, EnumKeysPipe], + providers: [ + { provide: 'startsWithOptions', useValue: options } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StartsWithTextComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + router = (comp as any).router; + spyOn(router, 'navigate'); + }); + + it('should create a FormGroup containing a startsWith FormControl', () => { + expect(comp.formData.value.startsWith).toBeDefined(); + }); + + describe('when selecting the first option in the dropdown', () => { + let select; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + select.value = select.options[0].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to undefined', () => { + expect(comp.startsWith).toBeUndefined(); + }); + + it('should not add a startsWith query parameter', () => { + route.queryParams.subscribe((params) => { + expect(params.startsWith).toBeUndefined(); + }); + }); + }); + + describe('when selecting "0-9" in the dropdown', () => { + let select; + let input; + const expectedValue = '0'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to "0"', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when selecting an option in the dropdown', () => { + let select; + let input; + const expectedValue = options[1]; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[2].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the expected value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when clicking an option in the list', () => { + let optionLink; + let input; + const expectedValue = options[1]; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + optionLink = fixture.debugElement.query(By.css('.list-inline-item:nth-child(2) > a')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + optionLink.click(); + fixture.detectChanges(); + }); + + it('should set startsWith to the expected value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when filling in the input form', () => { + let form; + const expectedValue = 'A'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('form')); + comp.formData.value.startsWith = expectedValue; + form.triggerEventHandler('ngSubmit', null); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + }); + +}); diff --git a/src/app/shared/starts-with/text/starts-with-text.component.ts b/src/app/shared/starts-with/text/starts-with-text.component.ts new file mode 100644 index 0000000000..ad89ce5c70 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.ts @@ -0,0 +1,49 @@ +import { Component, Inject } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Text". + */ +@Component({ + selector: 'ds-starts-with-text', + styleUrls: ['./starts-with-text.component.scss'], + templateUrl: './starts-with-text.component.html' +}) +@renderStartsWithFor(StartsWithType.text) +export class StartsWithTextComponent extends StartsWithAbstractComponent { + + /** + * Get startsWith as text; + */ + getStartsWith() { + if (hasValue(this.startsWith)) { + return this.startsWith; + } else { + return ''; + } + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '0-9') { + this.startsWith = '0'; + } + super.setStartsWithParam(); + } + + /** + * Checks whether the provided option is equal to the current startsWith + * @param option + */ + isSelectedOption(option: string): boolean { + if (this.startsWith === '0' && option === '0-9') { + return true; + } + return option === this.startsWith; + } + +} diff --git a/src/app/shared/utils/auto-focus.directive.ts b/src/app/shared/utils/auto-focus.directive.ts new file mode 100644 index 0000000000..a2d860a8e1 --- /dev/null +++ b/src/app/shared/utils/auto-focus.directive.ts @@ -0,0 +1,28 @@ +import { Directive, AfterViewInit, ElementRef, Input } from '@angular/core'; +import { isNotEmpty } from '../empty.util'; + +/** + * Directive to set focus on an element when it is rendered + */ +@Directive({ + selector: '[dsAutoFocus]' +}) +export class AutoFocusDirective implements AfterViewInit { + + /** + * Optional input to specify which element in a component should get the focus + * If left empty, the component itself will get the focus + */ + @Input() autoFocusSelector: string = undefined; + + constructor(private el: ElementRef) { + } + + ngAfterViewInit() { + if (isNotEmpty(this.autoFocusSelector)) { + return this.el.nativeElement.querySelector(this.autoFocusSelector).focus(); + } else { + return this.el.nativeElement.focus(); + } + } +} diff --git a/src/app/shared/utils/click-outside.directive.ts b/src/app/shared/utils/click-outside.directive.ts index e8efdf2d7a..b9397c65e5 100644 --- a/src/app/shared/utils/click-outside.directive.ts +++ b/src/app/shared/utils/click-outside.directive.ts @@ -16,9 +16,11 @@ export class ClickOutsideDirective { constructor(private _elementRef: ElementRef) { } - @HostListener('document:click', ['$event.target']) - public onClick(targetElement) { - const clickedInside = this._elementRef.nativeElement.contains(targetElement); + @HostListener('document:click') + public onClick() { + const hostElement = this._elementRef.nativeElement; + const focusElement = hostElement.ownerDocument.activeElement; + const clickedInside = hostElement.contains(focusElement); if (!clickedInside) { this.dsClickOutside.emit(null); } diff --git a/src/app/shared/utils/debounce.directive.ts b/src/app/shared/utils/debounce.directive.ts index a84a2d379e..8830679e2b 100644 --- a/src/app/shared/utils/debounce.directive.ts +++ b/src/app/shared/utils/debounce.directive.ts @@ -25,11 +25,6 @@ export class DebounceDirective implements OnInit, OnDestroy { @Input() public dsDebounce = 500; - /** - * True if no changes have been made to the input field's value - */ - private isFirstChange = true; - /** * Subject to unsubscribe from */ @@ -46,11 +41,9 @@ export class DebounceDirective implements OnInit, OnDestroy { this.model.valueChanges.pipe( takeUntil(this.subject), debounceTime(this.dsDebounce), - distinctUntilChanged(),) + distinctUntilChanged()) .subscribe((modelValue) => { - if (this.isFirstChange) { - this.isFirstChange = false; - } else { + if (this.model.dirty) { this.onDebounce.emit(modelValue); } }); diff --git a/src/app/shared/utils/in-list-validator.directive.ts b/src/app/shared/utils/in-list-validator.directive.ts new file mode 100644 index 0000000000..42ff7da1fd --- /dev/null +++ b/src/app/shared/utils/in-list-validator.directive.ts @@ -0,0 +1,29 @@ +import { Directive, Input } from '@angular/core'; +import { FormControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; +import { inListValidator } from './validator.functions'; + +/** + * Directive for validating if a ngModel value is in a given list + */ +@Directive({ + selector: '[ngModel][dsInListValidator]', + // We add our directive to the list of existing validators + providers: [ + { provide: NG_VALIDATORS, useExisting: InListValidator, multi: true } + ] +}) +export class InListValidator implements Validator { + /** + * The list to look in + */ + @Input() + dsInListValidator: string[]; + + /** + * The function that checks if the form control's value is currently valid + * @param c The FormControl + */ + validate(c: FormControl): ValidationErrors | null { + return inListValidator(this.dsInListValidator)(c); + } +} diff --git a/src/app/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts new file mode 100644 index 0000000000..79efd1cb76 --- /dev/null +++ b/src/app/shared/utils/object-values-pipe.ts @@ -0,0 +1,18 @@ +import { PipeTransform, Pipe } from '@angular/core'; + +@Pipe({name: 'dsObjectValues'}) +/** + * Pipe for parsing all values of an object to an array of values + */ +export class ObjectValuesPipe implements PipeTransform { + + /** + * @param value An object + * @returns {any} Array with all values of the input object + */ + transform(value, args:string[]): any { + const values = []; + Object.values(value).forEach((v) => values.push(v)); + return values; + } +} diff --git a/src/app/shared/utils/validator.functions.ts b/src/app/shared/utils/validator.functions.ts new file mode 100644 index 0000000000..464a4f5487 --- /dev/null +++ b/src/app/shared/utils/validator.functions.ts @@ -0,0 +1,17 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; +import { isNotEmpty } from '../empty.util'; + +/** + * Returns a validator function to check if the control's value is in a given list + * @param list The list to look in + */ +export function inListValidator(list: string[]): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + const hasValue = isNotEmpty(control.value); + let inList = true; + if (isNotEmpty(list)) { + inList = list.indexOf(control.value) > -1; + } + return (hasValue && inList) ? null : { inList: { value: control.value } } + }; +} diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index d773bef751..8bbdd4e0ee 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -715,6 +715,38 @@ describe('SubmissionObjectEffects test suite', () => { }); }); + describe('saveError$', () => { + it('should display a new error notification', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.saveError$.subscribe(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); + + it('should display a new error notification', () => { + actions = hot('--a-', { + a: { + type: SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR, + payload: { + submissionId: submissionId + } + } + }); + + submissionObjectEffects.saveError$.subscribe(() => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); + }); + describe('discardSubmission$', () => { it('should return a DISCARD_SUBMISSION_SUCCESS action on success', () => { store.nextState({ diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index 2127836c22..bebec482c2 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -137,6 +137,11 @@ export class SubmissionObjectEffects { catchError(() => observableOf(new SaveSubmissionSectionFormErrorAction(action.payload.submissionId)))); })); + @Effect({dispatch: false}) saveError$ = this.actions$.pipe( + ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR, SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR), + withLatestFrom(this.store$), + tap(() => this.notificationsService.error(null, this.translate.get('submission.sections.general.save_error_notice')))); + @Effect() saveAndDeposit$ = this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION), withLatestFrom(this.store$), diff --git a/src/app/submission/sections/upload/file/view/file-view.component.spec.ts b/src/app/submission/sections/upload/file/view/file-view.component.spec.ts index 4b3710661e..f5e924f309 100644 --- a/src/app/submission/sections/upload/file/view/file-view.component.spec.ts +++ b/src/app/submission/sections/upload/file/view/file-view.component.spec.ts @@ -8,7 +8,7 @@ import { mockUploadFiles } from '../../../../../shared/mocks/mock-submission'; import { FormComponent } from '../../../../../shared/form/form.component'; import { UploadSectionFileViewComponent } from './file-view.component'; import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; -import { Metadata } from '../../../../../core/shared/metadata.model'; +import { Metadata } from '../../../../../core/shared/metadata.utils'; describe('UploadSectionFileViewComponent test suite', () => { diff --git a/src/app/submission/sections/upload/file/view/file-view.component.ts b/src/app/submission/sections/upload/file/view/file-view.component.ts index e2b28447fb..0e94608069 100644 --- a/src/app/submission/sections/upload/file/view/file-view.component.ts +++ b/src/app/submission/sections/upload/file/view/file-view.component.ts @@ -2,8 +2,8 @@ import { Component, Input, OnInit } from '@angular/core'; import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { isNotEmpty } from '../../../../../shared/empty.util'; -import { MetadataMap, MetadataValue } from '../../../../../core/shared/metadata.interfaces'; -import { Metadata } from '../../../../../core/shared/metadata.model'; +import { Metadata } from '../../../../../core/shared/metadata.utils'; +import { MetadataMap, MetadataValue } from '../../../../../core/shared/metadata.models'; @Component({ selector: 'ds-submission-upload-section-file-view', diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts index f892a554e9..7dde19a306 100644 --- a/src/app/submission/submission.service.spec.ts +++ b/src/app/submission/submission.service.spec.ts @@ -772,7 +772,7 @@ describe('SubmissionService test suite', () => { it('should return true/false when section is loading/not loading', fakeAsync(() => { spyOn((service as any).translate, 'get').and.returnValue(observableOf('test')); - spyOn((service as any).notificationsService, 'info').and.callThrough(); + spyOn((service as any).notificationsService, 'info'); service.notifyNewSection(submissionId, sectionId); flush(); @@ -864,7 +864,7 @@ describe('SubmissionService test suite', () => { }) ); - const result = service.retrieveSubmission('826').subscribe((r) => { + service.retrieveSubmission('826').subscribe((r) => { expect(r).toEqual(new RemoteData( false, false, diff --git a/src/config/browse-by-config.interface.ts b/src/config/browse-by-config.interface.ts new file mode 100644 index 0000000000..6adba66b92 --- /dev/null +++ b/src/config/browse-by-config.interface.ts @@ -0,0 +1,21 @@ +import { Config } from './config.interface'; + +/** + * Config that determines how the dropdown list of years are created for browse-by-date components + */ +export interface BrowseByConfig extends Config { + /** + * The max amount of years to display using jumps of one year (current year - oneYearLimit) + */ + oneYearLimit: number; + + /** + * Limit for years to display using jumps of five years (current year - fiveYearLimit) + */ + fiveYearLimit: number; + + /** + * The absolute lowest year to display in the dropdown when no lowest date can be found for all items + */ + defaultLowerLimit: number; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index b449a18be4..d83ec6e4d8 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -6,6 +6,8 @@ import { INotificationBoardOptions } from './notifications-config.interfaces'; import { SubmissionConfig } from './submission-config.interface'; import { FormConfig } from './form-config.interfaces'; import {LangConfig} from './lang-config.interface'; +import { BrowseByConfig } from './browse-by-config.interface'; +import { ItemPageConfig } from './item-page-config.interface'; export interface GlobalConfig extends Config { ui: ServerConfig; @@ -21,4 +23,6 @@ export interface GlobalConfig extends Config { debug: boolean; defaultLanguage: string; languages: LangConfig[]; + browseBy: BrowseByConfig; + item: ItemPageConfig; } diff --git a/src/config/item-page-config.interface.ts b/src/config/item-page-config.interface.ts new file mode 100644 index 0000000000..c76d2cdb01 --- /dev/null +++ b/src/config/item-page-config.interface.ts @@ -0,0 +1,7 @@ +import { Config } from './config.interface'; + +export interface ItemPageConfig extends Config { + edit: { + undoTimeout: number; + } +} diff --git a/src/main.browser.ts b/src/main.browser.ts index 8409a96485..264399a4b8 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -27,7 +27,7 @@ export function main() { addGoogleAnalytics(); - return platformBrowserDynamic().bootstrapModule(BrowserAppModule); + return platformBrowserDynamic().bootstrapModule(BrowserAppModule, {preserveWhitespaces:true}); } function addGoogleAnalytics() { diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 2739503284..7be76ff5d3 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -19,12 +19,11 @@ $gray-700: lighten($gray-base, 46.6%) !default; // #777 $gray-600: lighten($gray-base, 73.3%) !default; // #bbb $gray-100: lighten($gray-base, 93.5%) !default; // #eee - /* Reassign color vars to semantic color scheme */ $blue: #2B4E72 !default; $green: #94BA65 !default; $cyan: #2790B0 !default; -$yellow: #EBBB54 !default; +$yellow: #ec9433 !default; $red: #CF4444 !default; $dark: darken($blue, 17%) !default; @@ -56,3 +55,4 @@ $grid-breakpoints: ( xl: (1200px - $collapsed-sidebar-width) ) !default; +$yiq-contrasted-threshold: 165 !default; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 10282132c2..716002327a 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -1,6 +1,7 @@ $content-spacing: $spacer * 1.5; $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2); + $card-height-percentage:98%; $card-thumbnail-height:240px; $dropdown-menu-max-height: 200px; @@ -27,3 +28,7 @@ $dark-scrollbar-background: $admin-sidebar-active-bg; $dark-scrollbar-foreground: #47495d; $submission-sections-margin-bottom: .5rem !default; + +$edit-item-button-min-width: 100px; +$edit-item-metadata-field-width: 190px; +$edit-item-language-field-width: 43px;