diff --git a/.codecov.yml b/.codecov.yml index 3dba42ef37..326dd3e0b2 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -17,10 +17,9 @@ coverage: # Configuration for patch-level checks. This checks the relative coverage of the new PR code ONLY. patch: default: - # For each PR, make sure the coverage of the new code is within 1% of current overall coverage. - # We let 'patch' be more lenient as we only require *project* coverage to not drop significantly. - target: auto - threshold: 1% + # Enable informational mode, which just provides info to reviewers & always passes + # https://docs.codecov.io/docs/commit-status#section-informational + informational: true # Turn PR comments "off". This feature adds the code coverage summary as a # comment on each PR. See https://docs.codecov.io/docs/pull-request-comments diff --git a/.editorconfig b/.editorconfig index 70ce43b68e..15d4c87b14 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,3 +12,6 @@ trim_trailing_whitespace = true [*.md] insert_final_newline = false trim_trailing_whitespace = false + +[*.ts] +quote_type = single diff --git a/package.json b/package.json index 5ceb899322..60473fbffc 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "clean:bld": "rimraf build", "clean:node": "rimraf node_modules", "clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld", - "clean": "yarn run clean:prod && yarn run clean:node && yarn run clean:env", + "clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node", "clean:env": "rimraf src/environments/environment.ts", "sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts" }, @@ -88,6 +88,7 @@ "debug-loader": "^0.0.1", "deepmerge": "^4.2.2", "express": "4.16.2", + "express-rate-limit": "^5.1.3", "fast-json-patch": "^2.0.7", "file-saver": "^1.3.8", "filesize": "^6.1.0", diff --git a/scripts/set-env.ts b/scripts/set-env.ts index 0aa106538c..5eee22a4be 100644 --- a/scripts/set-env.ts +++ b/scripts/set-env.ts @@ -54,13 +54,6 @@ import(environmentFilePath) function generateEnvironmentFile(file: GlobalConfig): void { file.production = production; buildBaseUrls(file); - - // TODO remove workaround in beta 5 - if (file.rest.nameSpace.match("(.*)/api/?$") !== null) { - file.rest.nameSpace = getNameSpace(file.rest.nameSpace); - console.log(colors.white.bgMagenta.bold(`The rest.nameSpace property in your environment file or in your DSPACE_REST_NAMESPACE environment variable ends with '/api'.\nThis is deprecated. As '/api' isn't configurable on the rest side, it shouldn't be repeated in every environment file.\nPlease change the rest nameSpace to '${file.rest.nameSpace}'`)); - } - const contents = `export const environment = ` + JSON.stringify(file); writeFile(targetPath, contents, (err) => { if (err) { @@ -119,16 +112,5 @@ function getPort(port: number): string { } function getNameSpace(nameSpace: string): string { - // TODO remove workaround in beta 5 - const apiMatches = nameSpace.match("(.*)/api/?$"); - if (apiMatches != null) { - let newValue = '/' - if (hasValue(apiMatches[1])) { - newValue = apiMatches[1]; - } - return newValue; - } - else { - return nameSpace ? nameSpace.charAt(0) === '/' ? nameSpace : '/' + nameSpace : ''; - } + return nameSpace ? nameSpace.charAt(0) === '/' ? nameSpace : '/' + nameSpace : ''; } diff --git a/server.ts b/server.ts index c640a95ef4..202d5a58bc 100644 --- a/server.ts +++ b/server.ts @@ -28,12 +28,13 @@ import * as compression from 'compression'; import * as cookieParser from 'cookie-parser'; import { join } from 'path'; -import { enableProdMode, NgModuleFactory, Type } from '@angular/core'; +import { enableProdMode } from '@angular/core'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { hasValue, hasNoValue } from './src/app/shared/empty.util'; +import { hasNoValue, hasValue } from './src/app/shared/empty.util'; +import { UIServerConfig } from './src/config/ui-server-config.interface'; /* * Set path for the browser application's dist folder @@ -121,6 +122,19 @@ function cacheControl(req, res, next) { next(); } +/** + * Checks if the rateLimiter property is present + * When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled. + */ +if (hasValue((environment.ui as UIServerConfig).rateLimiter)) { + const RateLimit = require('express-rate-limit'); + const limiter = new RateLimit({ + windowMs: (environment.ui as UIServerConfig).rateLimiter.windowMs, + max: (environment.ui as UIServerConfig).rateLimiter.max + }); + app.use(limiter); +} + /* * Serve static resources (images, i18n messages, …) */ @@ -209,8 +223,9 @@ if (environment.ui.ssl) { certificate: certificate }); } else { + console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.'); - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] pem.createCertificate({ days: 1, diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts index 34db71db77..8ca02fa8ad 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts @@ -13,6 +13,7 @@ import { Collection } from '../../../../../core/shared/collection.model'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { getCollectionEditRoute } from '../../../../../+collection-page/collection-page-routing-paths'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; describe('CollectionAdminSearchResultGridElementComponent', () => { let component: CollectionAdminSearchResultGridElementComponent; @@ -26,6 +27,11 @@ describe('CollectionAdminSearchResultGridElementComponent', () => { searchResult.indexableObject = new Collection(); searchResult.indexableObject.uuid = id; } + + const linkService = jasmine.createSpyObj('linkService', { + resolveLink: {} + }); + beforeEach(async(() => { init(); TestBed.configureTestingModule({ @@ -39,6 +45,7 @@ describe('CollectionAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, + { provide: LinkService, useValue: linkService} ] }) .compileComponents(); diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts index 85c81d55a4..6a834dd753 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts @@ -14,8 +14,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { CommunityAdminSearchResultGridElementComponent } from './community-admin-search-result-grid-element.component'; import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model'; import { Community } from '../../../../../core/shared/community.model'; -import { CommunityAdminSearchResultListElementComponent } from '../../admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component'; import { getCommunityEditRoute } from '../../../../../+community-page/community-page-routing-paths'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; describe('CommunityAdminSearchResultGridElementComponent', () => { let component: CommunityAdminSearchResultGridElementComponent; @@ -29,6 +29,11 @@ describe('CommunityAdminSearchResultGridElementComponent', () => { searchResult.indexableObject = new Community(); searchResult.indexableObject.uuid = id; } + + const linkService = jasmine.createSpyObj('linkService', { + resolveLink: {} + }); + beforeEach(async(() => { init(); TestBed.configureTestingModule({ @@ -42,6 +47,7 @@ describe('CommunityAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, + { provide: LinkService, useValue: linkService} ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 98552ed40b..beb7413415 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -3,37 +3,41 @@ *ngVar="(collectionRD$ | async) as collectionRD">
- -
+ +
+
- - - - - + + + + + - - - - - - - - - - -
+ + + + + + + + + +
+
+ +
+
{ let comp: CreateCollectionPageComponent; @@ -29,7 +30,8 @@ describe('CreateCollectionPageComponent', () => { }, { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, { provide: Router, useValue: {} }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() } + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: RequestService, useValue: {}} ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); 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 ae31b94c3d..a38739c407 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 @@ -7,6 +7,7 @@ import { Collection } from '../../core/shared/collection.model'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import {RequestService} from '../../core/data/request.service'; /** * Component that represents the page where a user can create a new Collection @@ -26,8 +27,9 @@ export class CreateCollectionPageComponent extends CreateComColPageComponent { let comp: DeleteCollectionPageComponent; @@ -22,6 +23,7 @@ describe('DeleteCollectionPageComponent', () => { { provide: CollectionDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, { provide: NotificationsService, useValue: {} }, + { provide: RequestService, useValue: {} } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); 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 5f2bd89942..8daba0abfc 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 @@ -5,6 +5,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; import { TranslateService } from '@ngx-translate/core'; +import {RequestService} from '../../core/data/request.service'; /** * Component that represents the page where a user can delete an existing Collection @@ -22,8 +23,9 @@ export class DeleteCollectionPageComponent extends DeleteComColPageComponent { let dsoNameService; const collection = Object.assign(new Collection(), { - handle: '123456789/1', metadata: {'dc.title': ['Collection Name']} + metadata: {'dc.title': ['Collection Name'], 'dc.identifier.uri': [ { value: '123456789/1'}]} }); beforeEach(async(() => { diff --git a/src/app/+community-page/community-page.component.html b/src/app/+community-page/community-page.component.html index dfd1ce93d9..418e69ed10 100644 --- a/src/app/+community-page/community-page.component.html +++ b/src/app/+community-page/community-page.component.html @@ -2,24 +2,28 @@
-
- - - - - - - - - - - - - - - -
+
+
+ + + + + + + + + + + + + + +
+
+ +
+
diff --git a/src/app/+community-page/create-community-page/create-community-page.component.spec.ts b/src/app/+community-page/create-community-page/create-community-page.component.spec.ts index b4da0be4d1..c48e9158fb 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.spec.ts +++ b/src/app/+community-page/create-community-page/create-community-page.component.spec.ts @@ -12,6 +12,7 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { CreateCommunityPageComponent } from './create-community-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import {RequestService} from '../../core/data/request.service'; describe('CreateCommunityPageComponent', () => { let comp: CreateCommunityPageComponent; @@ -25,7 +26,8 @@ describe('CreateCommunityPageComponent', () => { { provide: CommunityDataService, useValue: { findById: () => observableOf({}) } }, { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, { provide: Router, useValue: {} }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() } + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: RequestService, useValue: {} } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); 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 30a2acbb0d..f9ed09f1cd 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 @@ -6,6 +6,7 @@ import { Router } from '@angular/router'; import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import {RequestService} from '../../core/data/request.service'; /** * Component that represents the page where a user can create a new Community @@ -24,8 +25,9 @@ export class CreateCommunityPageComponent extends CreateComColPageComponent { let comp: DeleteCommunityPageComponent; @@ -22,6 +23,7 @@ describe('DeleteCommunityPageComponent', () => { { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, { provide: NotificationsService, useValue: {} }, + { provide: RequestService, useValue: {}} ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); 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 9f1465a3c7..ec51076bbc 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 @@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; 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'; +import {RequestService} from '../../core/data/request.service'; /** * Component that represents the page where a user can delete an existing Community @@ -22,8 +23,10 @@ export class DeleteCommunityPageComponent extends DeleteComColPageComponent { let dsoNameService; const community = Object.assign(new Community(), { - handle: '123456789/1', metadata: {'dc.title': ['Community Name']} + metadata: {'dc.title': ['Community Name'], 'dc.identifier.uri': [ { value: '123456789/1'}]} }); beforeEach(async(() => { diff --git a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts index bde2b5a1b0..f3055d3e51 100644 --- a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts +++ b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -48,7 +48,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl ngOnInit(): void { observableCombineLatest(this.route.data, this.route.parent.data).pipe( map(([data, parentData]) => Object.assign({}, data, parentData)), - map((data) => data.item), + map((data) => data.dso), first(), map((data: RemoteData) => data.payload) ).subscribe((item: Item) => { 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 655582064c..2bd9a30ca3 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 @@ -47,7 +47,7 @@ export class EditItemPageComponent implements OnInit { 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)); + this.itemRD$ = this.route.data.pipe(map((data) => data.dso)); } /** diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts index c687c829eb..dcf70a30cb 100644 --- a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts @@ -74,7 +74,7 @@ describe('ItemAuthorizationsComponent test suite', () => { const routeStub = { data: observableOf({ - item: createSuccessfulRemoteDataObject(item) + dso: createSuccessfulRemoteDataObject(item) }) }; diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts index 8153990a02..8b89de7c89 100644 --- a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts @@ -75,7 +75,7 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { */ ngOnInit(): void { this.item$ = this.route.data.pipe( - map((data) => data.item), + map((data) => data.dso), getFirstSucceededRemoteDataWithNotEmptyPayload(), map((item: Item) => this.linkService.resolveLink( item, diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index f86c57d69e..5f6e3a06c4 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -140,7 +140,7 @@ describe('ItemBitstreamsComponent', () => { }); route = Object.assign({ parent: { - data: observableOf({ item: createMockRD(item) }) + data: observableOf({ dso: createMockRD(item) }) }, data: observableOf({}), url: url diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts index 15b860a782..9aeb1522a6 100644 --- a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts @@ -89,7 +89,7 @@ describe('ItemCollectionMapperComponent', () => { clearDiscoveryRequests: () => {} /* tslint:enable:no-empty */ }); - const activatedRouteStub = new ActivatedRouteStub({}, { item: mockItemRD }); + const activatedRouteStub = new ActivatedRouteStub({}, { dso: mockItemRD }); const translateServiceStub = { get: () => of('test-message of item ' + mockItem.name), onLangChange: new EventEmitter(), diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts index 1409e06ddb..df406f826b 100644 --- a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts +++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts @@ -92,7 +92,7 @@ export class ItemCollectionMapperComponent implements OnInit { } ngOnInit(): void { - this.itemRD$ = this.route.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable>; + this.itemRD$ = this.route.data.pipe(map((data) => data.dso)).pipe(getSucceededRemoteData()) as Observable>; this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; this.loadCollectionLists(); } diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts index 18cbd6e855..e7b454e92b 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts @@ -138,7 +138,7 @@ describe('ItemDeleteComponent', () => { routeStub = { data: observableOf({ - item: createSuccessfulRemoteDataObject(mockItem) + dso: createSuccessfulRemoteDataObject(mockItem) }) }; 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 index 8f0776e4d3..cf226f7733 100644 --- 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 @@ -4,7 +4,7 @@ {{metadata?.key?.split('.').join('.​')}}
- + >
{{"item.edit.metadata.metadatafield.invalid" | translate}} 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 index 60419f41b2..4ecdb21e24 100644 --- 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 @@ -20,9 +20,9 @@ import { } from '../../../../shared/remote-data.utils'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { EditInPlaceFieldComponent } from './edit-in-place-field.component'; -import { FilterInputSuggestionsComponent } from '../../../../shared/input-suggestions/filter-suggestions/filter-input-suggestions.component'; import { MockComponent, MockDirective } from 'ng-mocks'; import { DebounceDirective } from '../../../../shared/utils/debounce.directive'; +import { ValidationSuggestionsComponent } from '../../../../shared/input-suggestions/validation-suggestions/validation-suggestions.component'; let comp: EditInPlaceFieldComponent; let fixture: ComponentFixture; @@ -88,7 +88,7 @@ describe('EditInPlaceFieldComponent', () => { declarations: [ EditInPlaceFieldComponent, MockDirective(DebounceDirective), - MockComponent(FilterInputSuggestionsComponent) + MockComponent(ValidationSuggestionsComponent) ], providers: [ { provide: RegistryService, useValue: metadataFieldService }, 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 index ed9ab4a891..f30b5cc3b0 100644 --- 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 @@ -130,7 +130,7 @@ describe('ItemMetadataComponent', () => { routeStub = { data: observableOf({}), parent: { - data: observableOf({ item: createSuccessfulRemoteDataObject(item) }) + data: observableOf({ dso: createSuccessfulRemoteDataObject(item) }) } }; paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts index 77aefe2356..c8c49b118b 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts @@ -44,7 +44,7 @@ describe('ItemMoveComponent', () => { const routeStub = { data: observableOf({ - item: new RemoteData(false, false, true, null, { + dso: new RemoteData(false, false, true, null, { id: 'item1' }) }) diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts index abadd2ec4a..1a544af7dc 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts @@ -55,7 +55,7 @@ export class ItemMoveComponent implements OnInit { } ngOnInit(): void { - this.itemRD$ = this.route.data.pipe(map((data) => data.item), getSucceededRemoteData()) as Observable>; + this.itemRD$ = this.route.data.pipe(map((data) => data.dso), getSucceededRemoteData()) as Observable>; this.itemRD$.subscribe((rd) => { this.itemId = rd.payload.id; } diff --git a/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts b/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts index 95b08a9936..52ccbc2133 100644 --- a/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts @@ -51,7 +51,7 @@ describe('ItemPrivateComponent', () => { routeStub = { data: observableOf({ - item: createSuccessfulRemoteDataObject({ + dso: createSuccessfulRemoteDataObject({ id: 'fake-id' }) }) diff --git a/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts b/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts index 53df20bf04..1143874709 100644 --- a/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts @@ -51,7 +51,7 @@ describe('ItemPublicComponent', () => { routeStub = { data: observableOf({ - item: createSuccessfulRemoteDataObject({ + dso: createSuccessfulRemoteDataObject({ id: 'fake-id' }) }) diff --git a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts index 5e75b59292..005f330df9 100644 --- a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts @@ -51,7 +51,7 @@ describe('ItemReinstateComponent', () => { routeStub = { data: observableOf({ - item: createSuccessfulRemoteDataObject({ + dso: createSuccessfulRemoteDataObject({ id: 'fake-id' }) }) diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html index 1a7cc2e2df..5583de5fd5 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -1,15 +1,26 @@ -
{{getRelationshipMessageKey() | async | translate}}
+
+ {{getRelationshipMessageKey() | async | translate}} + +
+ [ngClass]="{ + 'alert-success': updateValue.changeType === 1, + 'alert-warning': updateValue.changeType === 0, + 'alert-danger': updateValue.changeType === 2 + }"> +
{{"item.edit.relationships.no-relationships" | translate}}
-
no relationships
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss index e0c20d299f..54498499d7 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss @@ -1,8 +1,8 @@ -.relationship-row:not(.alert-danger) { +.relationship-row:not(.alert) { padding: $alert-padding-y 0; } -.relationship-row.alert-danger { +.relationship-row.alert { margin-left: -$alert-padding-x; margin-right: -$alert-padding-x; margin-top: -1px; diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index 1d4d91da0b..cd583fd22b 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -1,5 +1,5 @@ import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs/internal/observable/of'; @@ -8,6 +8,7 @@ import { FieldChangeType } from '../../../../core/data/object-updates/object-upd import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { PaginatedList } from '../../../../core/data/paginated-list'; import { RelationshipTypeService } from '../../../../core/data/relationship-type.service'; +import { RelationshipService } from '../../../../core/data/relationship.service'; import { RemoteData } from '../../../../core/data/remote-data'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; @@ -15,6 +16,7 @@ import { Relationship } from '../../../../core/shared/item-relationships/relatio import { Item } from '../../../../core/shared/item.model'; import { PageInfo } from '../../../../core/shared/page-info.model'; import { getMockLinkService } from '../../../../shared/mocks/link-service.mock'; +import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; import { SharedModule } from '../../../../shared/shared.module'; import { EditRelationshipListComponent } from './edit-relationship-list.component'; @@ -22,72 +24,123 @@ let comp: EditRelationshipListComponent; let fixture: ComponentFixture; let de: DebugElement; +let linkService; let objectUpdatesService; -let entityTypeService; +let relationshipService; +let selectableListService; const url = 'http://test-url.com/test-url'; let item; +let entityType; +let relatedEntityType; let author1; let author2; let fieldUpdate1; let fieldUpdate2; -let relationship1; -let relationship2; +let relationships; let relationshipType; -let entityType; -let relatedEntityType; describe('EditRelationshipListComponent', () => { - beforeEach(() => { + beforeEach(async(() => { entityType = Object.assign(new ItemType(), { - id: 'entityType', + id: 'Publication', + uuid: 'Publication', + label: 'Publication', }); relatedEntityType = Object.assign(new ItemType(), { - id: 'relatedEntityType', + id: 'Author', + uuid: 'Author', + label: 'Author', }); relationshipType = Object.assign(new RelationshipType(), { id: '1', uuid: '1', + leftType: observableOf(new RemoteData( + false, + false, + true, + undefined, + entityType, + )), + rightType: observableOf(new RemoteData( + false, + false, + true, + undefined, + relatedEntityType, + )), leftwardType: 'isAuthorOfPublication', rightwardType: 'isPublicationOfAuthor', - leftType: observableOf(new RemoteData(false, false, true, undefined, entityType)), - rightType: observableOf(new RemoteData(false, false, true, undefined, relatedEntityType)), }); - relationship1 = Object.assign(new Relationship(), { - _links: { - self: { - href: url + '/2' - } - }, - id: '2', - uuid: '2', - leftId: 'author1', - rightId: 'publication', - leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), - rightItem: observableOf(new RemoteData(false, false, true, undefined, author1)), - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' }); - relationship2 = Object.assign(new Relationship(), { - _links: { - self: { - href: url + '/3' - } - }, - id: '3', - uuid: '3', - leftId: 'author2', - rightId: 'publication', - leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), - rightItem: observableOf(new RemoteData(false, false, true, undefined, author2)), - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) - }); + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData( + false, + false, + true, + undefined, + relationshipType + )), + leftItem: observableOf(new RemoteData( + false, + false, + true, + undefined, + item, + )), + rightItem: observableOf(new RemoteData( + false, + false, + true, + undefined, + author1, + )), + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + relationshipType: observableOf(new RemoteData( + false, + false, + true, + undefined, + relationshipType + )), + leftItem: observableOf(new RemoteData( + false, + false, + true, + undefined, + item, + )), + rightItem: observableOf(new RemoteData( + false, + false, + true, + undefined, + author2, + )), + }) + ]; item = Object.assign(new Item(), { _links: { @@ -100,84 +153,82 @@ describe('EditRelationshipListComponent', () => { false, true, undefined, - new PaginatedList(new PageInfo(), [relationship1, relationship2]) + new PaginatedList(new PageInfo(), relationships), )) }); - author1 = Object.assign(new Item(), { - id: 'author1', - uuid: 'author1' - }); - author2 = Object.assign(new Item(), { - id: 'author2', - uuid: 'author2' - }); - fieldUpdate1 = { - field: author1, + field: { + uuid: relationships[0].uuid, + relationship: relationships[0], + type: relationshipType, + }, changeType: undefined }; fieldUpdate2 = { - field: author2, + field: { + uuid: relationships[1].uuid, + relationship: relationships[1], + type: relationshipType, + }, changeType: FieldChangeType.REMOVE }; objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { getFieldUpdates: observableOf({ - [author1.uuid]: fieldUpdate1, - [author2.uuid]: fieldUpdate2 + [relationships[0].uuid]: fieldUpdate1, + [relationships[1].uuid]: fieldUpdate2 }) } ); - entityTypeService = jasmine.createSpyObj('entityTypeService', + relationshipService = jasmine.createSpyObj('relationshipService', { - getEntityTypeByLabel: observableOf(new RemoteData( - false, - false, - true, - null, - entityType, - )), - getEntityTypeRelationships: observableOf(new RemoteData( - false, - false, - true, - null, - new PaginatedList(new PageInfo(), [relationshipType]), - )), + getRelatedItemsByLabel: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [author1, author2]))), + getItemRelationshipsByLabel: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), relationships))), + isLeftItem: observableOf(true), } ); + selectableListService = {}; + + linkService = { + resolveLink: () => null, + resolveLinks: () => null, + }; + TestBed.configureTestingModule({ imports: [SharedModule, TranslateModule.forRoot()], declarations: [EditRelationshipListComponent], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: RelationshipTypeService, useValue: {} }, - { provide: LinkService, useValue: getMockLinkService() }, + { provide: RelationshipService, useValue: relationshipService }, + { provide: SelectableListService, useValue: selectableListService }, + { provide: LinkService, useValue: linkService }, ], schemas: [ NO_ERRORS_SCHEMA ] }).compileComponents(); + })); + beforeEach(() => { fixture = TestBed.createComponent(EditRelationshipListComponent); comp = fixture.componentInstance; de = fixture.debugElement; - comp.item = item; comp.itemType = entityType; comp.url = url; comp.relationshipType = relationshipType; - fixture.detectChanges(); }); describe('changeType is REMOVE', () => { - it('the div should have class alert-danger', () => { - + beforeEach(() => { fieldUpdate1.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class alert-danger', () => { const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; expect(element.classList).toContain('alert-danger'); }); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index c17762e4a0..a9434eef6f 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -1,11 +1,23 @@ import { Component, Input, OnInit } from '@angular/core'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { LinkService } from '../../../../core/cache/builders/link.service'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { Observable } from 'rxjs/internal/Observable'; -import {FieldUpdate, FieldUpdates} from '../../../../core/data/object-updates/object-updates.reducer'; +import { + FieldUpdate, + FieldUpdates, + RelationshipIdentifiable +} from '../../../../core/data/object-updates/object-updates.reducer'; +import { RelationshipService } from '../../../../core/data/relationship.service'; import {Item} from '../../../../core/shared/item.model'; -import { map, switchMap, tap } from 'rxjs/operators'; -import {hasValue} from '../../../../shared/empty.util'; +import { + defaultIfEmpty, filter, flatMap, + map, + switchMap, + take, tap, +} from 'rxjs/operators'; +import { hasValue } from '../../../../shared/empty.util'; import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model'; import { @@ -13,8 +25,13 @@ import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; -import { combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, of } from 'rxjs'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; +import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component'; +import { RelationshipOptions } from '../../../../shared/form/builder/models/relationship-options.model'; +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; +import { SearchResult } from '../../../../shared/search/search-result.model'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; @Component({ @@ -46,14 +63,29 @@ export class EditRelationshipListComponent implements OnInit { */ @Input() relationshipType: RelationshipType; + private relatedEntityType$: Observable; + + /** + * The list ID to save selected entities under + */ + listId: string; + /** * The FieldUpdates for the relationships in question */ updates$: Observable; + /** + * A reference to the lookup window + */ + modalRef: NgbModalRef; + constructor( protected objectUpdatesService: ObjectUpdatesService, - protected linkService: LinkService + protected linkService: LinkService, + protected relationshipService: RelationshipService, + protected modalService: NgbModal, + protected selectableListService: SelectableListService, ) { } @@ -62,10 +94,18 @@ export class EditRelationshipListComponent implements OnInit { */ public getRelationshipMessageKey(): Observable { - return this.getLabel().pipe( - map((label) => { - if (hasValue(label) && label.indexOf('Of') > -1) { - return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` + return observableCombineLatest( + this.getLabel(), + this.relatedEntityType$, + ).pipe( + map(([label, relatedEntityType]) => { + if (hasValue(label) && label.indexOf('is') > -1 && label.indexOf('Of') > -1) { + const relationshipLabel = `${label.substring(2, label.indexOf('Of'))}`; + if (relationshipLabel !== relatedEntityType.label) { + return `relationships.is${relationshipLabel}Of.${relatedEntityType.label}` + } else { + return `relationships.is${relationshipLabel}Of` + } } else { return label; } @@ -77,7 +117,6 @@ export class EditRelationshipListComponent implements OnInit { * Get the relevant label for this relationship type */ private getLabel(): Observable { - return observableCombineLatest([ this.relationshipType.leftType, this.relationshipType.rightType, @@ -99,19 +138,197 @@ export class EditRelationshipListComponent implements OnInit { return update && update.field ? update.field.uuid : undefined; } + /** + * Open the dynamic lookup modal to search for items to add as relationships + */ + openLookup() { + + this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, { + size: 'lg' + }); + const modalComp: DsDynamicLookupRelationModalComponent = this.modalRef.componentInstance; + modalComp.repeatable = true; + modalComp.listId = this.listId; + modalComp.item = this.item; + modalComp.select = (...selectableObjects: Array>) => { + selectableObjects.forEach((searchResult) => { + const relatedItem: Item = searchResult.indexableObject; + this.getFieldUpdatesForRelatedItem(relatedItem) + .subscribe((identifiables) => { + identifiables.forEach((identifiable) => + this.objectUpdatesService.removeSingleFieldUpdate(this.url, identifiable.uuid) + ); + if (identifiables.length === 0) { + this.relationshipService.getNameVariant(this.listId, relatedItem.uuid) + .subscribe((nameVariant) => { + const update = { + uuid: this.relationshipType.id + '-' + relatedItem.uuid, + nameVariant, + type: this.relationshipType, + relatedItem, + } as RelationshipIdentifiable; + this.objectUpdatesService.saveAddFieldUpdate(this.url, update); + }) + } + }); + }) + }; + modalComp.deselect = (...selectableObjects: Array>) => { + selectableObjects.forEach((searchResult) => { + const relatedItem: Item = searchResult.indexableObject; + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.relationshipType.id + '-' + relatedItem.uuid); + this.getFieldUpdatesForRelatedItem(relatedItem) + .subscribe((identifiables) => + identifiables.forEach((identifiable) => + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, identifiable) + ) + ); + }) + }; + this.relatedEntityType$ + .pipe(take(1)) + .subscribe((relatedEntityType) => { + modalComp.relationshipOptions = Object.assign( + new RelationshipOptions(), { + relationshipType: relatedEntityType.label, + // filter: this.getRelationshipMessageKey(), + searchConfiguration: relatedEntityType.label.toLowerCase(), + nameVariants: true, + } + ); + }); + + this.selectableListService.deselectAll(this.listId); + this.updates$.pipe( + switchMap((updates) => + Object.values(updates).length > 0 ? + observableCombineLatest( + Object.values(updates) + .filter((update) => update.changeType !== FieldChangeType.REMOVE) + .map((update) => { + const field = update.field as RelationshipIdentifiable; + if (field.relationship) { + return this.getRelatedItem(field.relationship); + } else { + return of(field.relatedItem); + } + }) + ) : of([]) + ), + take(1), + map((items) => items.map((item) => { + const searchResult = new ItemSearchResult(); + searchResult.indexableObject = item; + searchResult.hitHighlights = {}; + return searchResult; + })), + ).subscribe((items) => { + this.selectableListService.select(this.listId, items); + }); + } + + /** + * Get the existing field updates regarding a relationship with a given item + * @param relatedItem The item for which to get the existing field updates + */ + private getFieldUpdatesForRelatedItem(relatedItem: Item): Observable { + + return this.updates$.pipe( + take(1), + map((updates) => Object.values(updates) + .map((update) => update.field as RelationshipIdentifiable) + .filter((field) => field.relationship) + ), + flatMap((identifiables) => + observableCombineLatest( + identifiables.map((identifiable) => this.getRelatedItem(identifiable.relationship)) + ).pipe( + defaultIfEmpty([]), + map((relatedItems) => + identifiables.filter((identifiable, index) => relatedItems[index].uuid === relatedItem.uuid) + ), + ) + ), + ); + } + + /** + * Get the related item for a given relationship + * @param relationship The relationship for which to get the related item + */ + private getRelatedItem(relationship: Relationship): Observable { + return this.relationshipService.isLeftItem(relationship, this.item).pipe( + switchMap((isLeftItem) => isLeftItem ? relationship.rightItem : relationship.leftItem), + getSucceededRemoteData(), + getRemoteDataPayload(), + ) + } + ngOnInit(): void { - this.updates$ = this.item.relationships.pipe( + + this.relatedEntityType$ = + observableCombineLatest([ + this.relationshipType.leftType, + this.relationshipType.rightType, + ].map((type) => type.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ))).pipe( + map((relatedTypes) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)), + ); + + this.relatedEntityType$.pipe( + take(1) + ).subscribe( + (relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}` + ); + + this.updates$ = this.getItemRelationships().pipe( + switchMap((relationships) => + observableCombineLatest( + relationships.map((relationship) => this.relationshipService.isLeftItem(relationship, this.item)) + ).pipe( + defaultIfEmpty([]), + map((isLeftItemArray) => isLeftItemArray.map((isLeftItem, index) => { + const relationship = relationships[index]; + const nameVariant = isLeftItem ? relationship.rightwardValue : relationship.leftwardValue; + return { + uuid: relationship.id, + type: this.relationshipType, + relationship, + nameVariant, + } as RelationshipIdentifiable + })), + )), + switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields).pipe( + map((fieldUpdates) => { + const fieldUpdatesFiltered: FieldUpdates = {}; + Object.keys(fieldUpdates).forEach((uuid) => { + const field = fieldUpdates[uuid].field; + if ((field as RelationshipIdentifiable).type.id === this.relationshipType.id) { + fieldUpdatesFiltered[uuid] = fieldUpdates[uuid]; + } + }); + return fieldUpdatesFiltered; + }), + )), + ); + } + + private getItemRelationships() { + this.linkService.resolveLink(this.item, followLink('relationships')); + return this.item.relationships.pipe( getAllSucceededRemoteData(), map((relationships) => relationships.payload.page.filter((relationship) => relationship)), - map((relationships: Relationship[]) => - relationships.map((relationship: Relationship) => { + filter((relationships) => relationships.every((relationship) => !!relationship)), + tap((relationships: Relationship[]) => + relationships.forEach((relationship: Relationship) => { this.linkService.resolveLinks( relationship, followLink('relationshipType'), followLink('leftItem'), followLink('rightItem'), ); - return relationship; }) ), switchMap((itemRelationships: Relationship[]) => @@ -122,15 +339,12 @@ export class EditRelationshipListComponent implements OnInit { getRemoteDataPayload(), )) ).pipe( + defaultIfEmpty([]), map((relationshipTypes) => itemRelationships.filter( (relationship, index) => relationshipTypes[index].id === this.relationshipType.id) ), - map((relationships) => relationships.map((relationship) => - Object.assign(new Relationship(), relationship, {uuid: relationship.id}) - )), ) ), - switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields)), ); } } diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html index 7e61e8958f..e65cd237a3 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html @@ -1,6 +1,11 @@
- + +
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts index 9eca3f270d..6e81319f28 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts @@ -1,5 +1,5 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; @@ -25,7 +25,7 @@ let fieldUpdate2; let relationships; let relationshipType; -let fixture; +let fixture: ComponentFixture; let comp: EditRelationshipComponent; let de; let el; @@ -91,11 +91,17 @@ describe('EditRelationshipComponent', () => { }); fieldUpdate1 = { - field: relationships[0], + field: { + uuid: relationships[0].uuid, + relationship: relationships[0], + }, changeType: undefined }; fieldUpdate2 = { - field: relationships[1], + field: { + uuid: relationships[1].uuid, + relationship: relationships[1], + }, changeType: FieldChangeType.REMOVE }; diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts index 2badaf80b0..265bca7529 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -1,10 +1,13 @@ import { Component, Input, OnChanges, OnInit } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; import { filter, map, switchMap, take, tap } from 'rxjs/operators'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; -import { DeleteRelationship, FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { + DeleteRelationship, + FieldUpdate, + RelationshipIdentifiable +} from '../../../../core/data/object-updates/object-updates.reducer'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Item } from '../../../../core/shared/item.model'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; @@ -36,8 +39,16 @@ export class EditRelationshipComponent implements OnChanges { /** * The relationship being edited */ - get relationship(): Relationship { - return this.fieldUpdate.field as Relationship; + get relationship() { + return this.update.relationship; + } + + get update() { + return this.fieldUpdate.field as RelationshipIdentifiable; + } + + get nameVariant() { + return this.update.nameVariant; } public leftItem$: Observable; @@ -68,24 +79,28 @@ export class EditRelationshipComponent implements OnChanges { * Sets the current relationship based on the fieldUpdate input field */ ngOnChanges(): void { - this.leftItem$ = this.relationship.leftItem.pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) - ); - this.rightItem$ = this.relationship.rightItem.pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) - ); - this.relatedItem$ = observableCombineLatest( - this.leftItem$, - this.rightItem$, - ).pipe( - map((items: Item[]) => - items.find((item) => item.uuid !== this.editItem.uuid) - ) - ); + if (this.relationship) { + this.leftItem$ = this.relationship.leftItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ); + this.rightItem$ = this.relationship.rightItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ); + this.relatedItem$ = observableCombineLatest( + this.leftItem$, + this.rightItem$, + ).pipe( + map((items: Item[]) => + items.find((item) => item.uuid !== this.editItem.uuid) + ) + ); + } else { + this.relatedItem$ = of(this.update.relatedItem); + } } /** @@ -136,7 +151,8 @@ export class EditRelationshipComponent implements OnChanges { * Check if a user should be allowed to remove this field */ canRemove(): boolean { - return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; + return this.fieldUpdate.changeType !== FieldChangeType.REMOVE + && this.fieldUpdate.changeType !== FieldChangeType.ADD; } /** diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html index 0c9d92dfbf..7692494fd8 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -19,14 +19,19 @@  {{"item.edit.metadata.save-button" | translate}}
-
- -
+ + +
+ +
+
+ +
diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index be96ff1a0a..49065c5ec5 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,4 +1,4 @@ -import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { take } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { FindListOptions } from '../../core/data/request.models'; @@ -24,15 +24,14 @@ export class CommunityListComponent implements OnInit, OnDestroy { public loadingNode: FlatNode; treeControl = new FlatTreeControl( - (node) => node.level, (node) => true + (node: FlatNode) => node.level, (node: FlatNode) => true ); dataSource: CommunityListDatasource; paginationConfig: FindListOptions; - constructor(private communityListService: CommunityListService, - private zone: NgZone) { + constructor(private communityListService: CommunityListService) { this.paginationConfig = new FindListOptions(); this.paginationConfig.elementsPerPage = 2; this.paginationConfig.currentPage = 1; @@ -40,7 +39,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { } ngOnInit() { - this.dataSource = new CommunityListDatasource(this.communityListService, this.zone); + this.dataSource = new CommunityListDatasource(this.communityListService); this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => { this.loadingNode = result; }); @@ -65,7 +64,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { } /** - * Toggles the expanded variable of a node, adds it to the exapanded nodes list and reloads the tree so this node is expanded + * Toggles the expanded variable of a node, adds it to the expanded nodes list and reloads the tree so this node is expanded * @param node Node we want to expand */ toggleExpanded(node: FlatNode) { diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index 95a7570207..871be7e9ae 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -6,7 +6,6 @@ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; import * as decorators from './build-decorators'; -import { getDataServiceFor } from './build-decorators'; import { LinkService } from './link.service'; const spyOnFunction = (obj: T, func: keyof T) => { diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index dc65eab68f..8d0def71fc 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -27,7 +27,7 @@ export class LinkService { */ public resolveLinks(model: T, ...linksToFollow: Array>): T { linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { - this.resolveLink(model, linkToFollow); + this.resolveLink(model, linkToFollow); }); return model; } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 1ba19df18c..f48022e6f1 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -17,6 +17,7 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { FindByIDRequest, FindListOptions } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; +import {createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$} from '../../shared/remote-data.utils'; const LINK_NAME = 'test'; @@ -51,7 +52,9 @@ describe('ComColDataService', () => { let objectCache: ObjectCacheService; let halService: any = {}; - const rdbService = {} as RemoteDataBuildService; + const rdbService = { + buildSingle : () => null + } as any; const store = {} as Store; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; @@ -178,6 +181,90 @@ describe('ComColDataService', () => { }); }); + describe('cache refresh', () => { + let communityWithoutParentHref; + let data; + + beforeEach(() => { + scheduler = getTestScheduler(); + halService = { + getEndpoint: (linkPath) => 'https://rest.api/core/' + linkPath + }; + service = initTestService(); + + }) + describe('cache refreshed top level community', () => { + beforeEach(() => { + spyOn(rdbService, 'buildSingle').and.returnValue(createNoContentRemoteDataObject$()); + data = { + dso: Object.assign(new Community(), { + metadata: [{ + key: 'dc.title', + value: 'top level community' + }] + }), + _links: { + parentCommunity: { + href: 'topLevel/parentCommunity' + } + } + }; + communityWithoutParentHref = { + dso: Object.assign(new Community(), { + metadata: [{ + key: 'dc.title', + value: 'top level community' + }] + }), + _links: {} + }; + }); + it('top level community cache refreshed', () => { + scheduler.schedule(() => (service as any).refreshCache(data)); + scheduler.flush(); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith('https://rest.api/core/communities/search/top'); + }); + it('top level community without parent link, cache not refreshed', () => { + scheduler.schedule(() => (service as any).refreshCache(communityWithoutParentHref)); + scheduler.flush(); + expect(requestService.removeByHrefSubstring).not.toHaveBeenCalled(); + }); + }); + + describe('cache refreshed child community', () => { + beforeEach(() => { + const parentCommunity = Object.assign(new Community(), { + uuid: 'a20da287-e174-466a-9926-f66as300d399', + id: 'a20da287-e174-466a-9926-f66as300d399', + metadata: [{ + key: 'dc.title', + value: 'parent community' + }], + _links: {} + }); + spyOn(rdbService, 'buildSingle').and.returnValue(createSuccessfulRemoteDataObject$(parentCommunity)); + data = { + dso: Object.assign(new Community(), { + metadata: [{ + key: 'dc.title', + value: 'child community' + }] + }), + _links: { + parentCommunity: { + href: 'child/parentCommunity' + } + } + }; + }); + it('child level community cache refreshed', () => { + scheduler.schedule(() => (service as any).refreshCache(data)); + scheduler.flush(); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith('a20da287-e174-466a-9926-f66as300d399'); + }); + }); + }); + }); }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index d83518a3b0..a06a6bac9a 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -21,12 +21,14 @@ import { configureRequest, getRemoteDataPayload, getResponseFromEntry, + getSucceededOrNoContentResponse, getSucceededRemoteData } from '../shared/operators'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RestResponse } from '../cache/response.models'; import { Bitstream } from '../shared/bitstream.model'; import { DSpaceObject } from '../shared/dspace-object.model'; +import {Collection} from '../shared/collection.model'; export abstract class ComColDataService extends DataService { protected abstract cds: CommunityDataService; @@ -119,4 +121,23 @@ export abstract class ComColDataService extends DataS ); } } + + public refreshCache(dso: T) { + const parentCommunityUrl = this.parentCommunityUrlLookup(dso as any); + if (!hasValue(parentCommunityUrl)) { + return; + } + this.findByHref(parentCommunityUrl).pipe( + getSucceededOrNoContentResponse(), + take(1), + ).subscribe((rd: RemoteData) => { + const href = rd.hasSucceeded && !isEmpty(rd.payload.id) ? rd.payload.id : this.halService.getEndpoint('communities/search/top'); + this.requestService.removeByHrefSubstring(href) + }); + } + + private parentCommunityUrlLookup(dso: Collection | Community) { + const parentCommunity = dso._links.parentCommunity; + return isNotEmpty(parentCommunity) ? parentCommunity.href : null; + } } diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts index 87259a4279..8ae7873c55 100644 --- a/src/app/core/data/dso-redirect-data.service.ts +++ b/src/app/core/data/dso-redirect-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { take, tap } from 'rxjs/operators'; +import { filter, take, tap } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -11,7 +11,6 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFinishedRemoteData } from '../shared/operators'; import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RemoteData } from './remote-data'; @@ -56,7 +55,7 @@ export class DsoRedirectDataService extends DataService { findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { this.setLinkPath(identifierType); return this.findById(id).pipe( - getFinishedRemoteData(), + filter((response) => hasValue(response.error) || hasValue(response.payload)), take(1), tap((response) => { if (response.hasSucceeded) { diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts index b8e8b7cd9a..43a9e3fce6 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type.service.ts @@ -12,11 +12,12 @@ import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; import { GetRequest } from './request.models'; import { Observable } from 'rxjs/internal/Observable'; -import {switchMap, take, tap} from 'rxjs/operators'; +import {switchMap, take, map} from 'rxjs/operators'; import { RemoteData } from './remote-data'; import {RelationshipType} from '../shared/item-relationships/relationship-type.model'; import {PaginatedList} from './paginated-list'; import {ItemType} from '../shared/item-relationships/item-type.model'; +import {getRemoteDataPayload, getSucceededRemoteData} from '../shared/operators'; /** * Service handling all ItemType requests @@ -51,6 +52,20 @@ export class EntityTypeService extends DataService { ); } + /** + * Check whether a given entity type is the left type of a given relationship type, as an observable boolean + * @param relationshipType the relationship type for which to check whether the given entity type is the left type + * @param entityType the entity type for which to check whether it is the left type of the given relationship type + */ + isLeftType(relationshipType: RelationshipType, itemType: ItemType): Observable { + + return relationshipType.leftType.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((leftType) => leftType.uuid === itemType.uuid), + ); + } + /** * Get the allowed relationship types for an entity type * @param entityTypeId diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 450d5057aa..3c17f43cb1 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -5,6 +5,7 @@ export enum FeatureID { LoginOnBehalfOf = 'loginOnBehalfOf', AdministratorOf = 'administratorOf', CanDelete = 'canDelete', + CanEditMetadata = 'canEditMetadata', WithdrawItem = 'withdrawItem', ReinstateItem = 'reinstateItem', EPersonRegistration = 'epersonRegistration', diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 94bb845aa8..378131ecf8 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -13,9 +13,11 @@ import { SelectVirtualMetadataAction, } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import {Relationship} from '../../shared/item-relationships/relationship.model'; +import { Relationship} from '../../shared/item-relationships/relationship.model'; import { InjectionToken } from '@angular/core'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; +import { Item} from '../../shared/item.model'; +import { RelationshipType} from '../../shared/item-relationships/relationship-type.model'; /** * Path where discarded objects are saved @@ -74,11 +76,18 @@ export interface VirtualMetadataSource { [uuid: string]: boolean, } +export interface RelationshipIdentifiable extends Identifiable { + nameVariant?: string, + relatedItem: Item; + relationship: Relationship; + type: RelationshipType; +} + /** * A fieldupdate interface which represents a relationship selected to be deleted, * along with a selection of the virtual metadata to keep */ -export interface DeleteRelationship extends Relationship { +export interface DeleteRelationship extends RelationshipIdentifiable { keepLeftVirtualMetadata: boolean, keepRightVirtualMetadata: boolean, } @@ -189,7 +198,7 @@ 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] || {}; + const pageState: ObjectUpdatesEntry = state[url] || {fieldUpdates: {}}; let states = pageState.fieldStates; if (changeType === FieldChangeType.ADD) { diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 8bd32e54e2..11c9df272d 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -24,7 +24,7 @@ import { SetValidFieldUpdateAction } from './object-updates.actions'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; import { Operation } from 'fast-json-patch'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; @@ -129,7 +129,7 @@ export class ObjectUpdatesService { */ getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable { const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe(isNotEmptyOperator(), map((objectEntry) => { + return objectUpdates.pipe(map((objectEntry) => { const fieldUpdates: FieldUpdates = {}; for (const object of initialFields) { let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 6d9b237db0..a3f49e0bc9 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -102,8 +102,8 @@ export class RelationshipService extends DataService { ), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), - getResponseFromEntry(), tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)), + getResponseFromEntry(), ); } @@ -129,9 +129,9 @@ export class RelationshipService extends DataService { map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, `${item1.self} \n ${item2.self}`, options)), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), - getResponseFromEntry(), tap(() => this.refreshRelationshipItemsInCache(item1)), - tap(() => this.refreshRelationshipItemsInCache(item2)) + tap(() => this.refreshRelationshipItemsInCache(item2)), + getResponseFromEntry(), ) as Observable; } @@ -400,6 +400,20 @@ export class RelationshipService extends DataService { ); } + /** + * Check whether a given item is the left item of a given relationship, as an observable boolean + * @param relationship the relationship for which to check whether the given item is the left item + * @param item the item for which to check whether it is the left item of the given relationship + */ + public isLeftItem(relationship: Relationship, item: Item): Observable { + return relationship.leftItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((leftItem: Item) => hasValue(leftItem) && isNotEmpty(leftItem.uuid)), + map((leftItem) => leftItem.uuid === item.uuid) + ); + } + /** * Method to update the the right or left place of a relationship * The useLeftItem field in the reorderable relationship determines which place should be updated diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index 8502c8ba1d..17fbe6a8a6 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -55,4 +55,8 @@ export class RemoteData { return this.state === RemoteDataState.Success; } + get hasNoContent(): boolean { + return this.statusCode === 204; + } + } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index a1a6951545..6fde34b9a5 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -303,7 +303,7 @@ describe('EPersonDataService', () => { it('should sent a patch request with an uuid, token and new password to the epersons endpoint', () => { service.patchPasswordWithToken('test-uuid', 'test-token','test-password'); - const operation = Object.assign({ op: 'replace', path: '/password', value: 'test-password' }); + const operation = Object.assign({ op: 'add', path: '/password', value: 'test-password' }); const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/test-uuid?token=test-token', [operation]); expect(requestService.configure).toHaveBeenCalledWith(expected); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index a1428aee73..5fc4c6497f 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -280,7 +280,7 @@ export class EPersonDataService extends DataService { patchPasswordWithToken(uuid: string, token: string, password: string): Observable { const requestId = this.requestService.generateRequestId(); - const operation = Object.assign({ op: 'replace', path: '/password', value: password }); + const operation = Object.assign({ op: 'add', path: '/password', value: password }); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, uuid)), diff --git a/src/app/core/shared/collection.model.spec.ts b/src/app/core/shared/collection.model.spec.ts new file mode 100644 index 0000000000..b35fa7415b --- /dev/null +++ b/src/app/core/shared/collection.model.spec.ts @@ -0,0 +1,24 @@ +import {Collection} from './collection.model'; + +describe('Collection', () => { + + describe('Collection handle value', () => { + + let metadataValue; + + beforeEach(() => { + metadataValue = {'dc.identifier.uri': [ { value: '123456789/1'}]}; + }) + + it('should return the handle value from metadata', () => { + const community = Object.assign(new Collection(), { metadata: metadataValue }); + expect(community.handle).toEqual('123456789/1'); + }); + + it('should return undefined if the handle value from metadata is not present', () => { + const community = Object.assign(new Collection(), { }); + expect(community.handle).toEqual(undefined); + }); + }); + +}); diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index c1464d7d39..a82f0646c5 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; import { link, typedObject } from '../cache/builders/build-decorators'; import { PaginatedList } from '../data/paginated-list'; @@ -21,12 +21,6 @@ import { ChildHALResource } from './child-hal-resource.model'; export class Collection extends DSpaceObject implements ChildHALResource { static type = COLLECTION; - /** - * A string representing the unique handle of this Collection - */ - @autoserialize - handle: string; - /** * The {@link HALLink}s for this Collection */ @@ -75,6 +69,13 @@ export class Collection extends DSpaceObject implements ChildHALResource { @link(COMMUNITY, false) parentCommunity?: Observable>; + /** + * A string representing the unique handle of this Collection + */ + get handle(): string { + return this.firstMetadataValue('dc.identifier.uri'); + } + /** * The introductory text of this Collection * Corresponds to the metadata field dc.description diff --git a/src/app/core/shared/community.model.spec.ts b/src/app/core/shared/community.model.spec.ts new file mode 100644 index 0000000000..5697686853 --- /dev/null +++ b/src/app/core/shared/community.model.spec.ts @@ -0,0 +1,24 @@ +import {Community} from './community.model'; + +describe('Community', () => { + + describe('Community handle value', () => { + + let metadataValue; + + beforeEach(() => { + metadataValue = {'dc.identifier.uri': [ { value: '123456789/1'}]}; + }) + + it('should return the handle value from metadata', () => { + const community = Object.assign(new Community(), { metadata: metadataValue }); + expect(community.handle).toEqual('123456789/1'); + }); + + it('should return undefined if the handle value from metadata is not present', () => { + const community = Object.assign(new Community(), { }); + expect(community.handle).toEqual(undefined); + }); + }); + +}); diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index 796aaa8ece..778696486b 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; import { link, typedObject } from '../cache/builders/build-decorators'; import { PaginatedList } from '../data/paginated-list'; @@ -17,12 +17,6 @@ import { ChildHALResource } from './child-hal-resource.model'; export class Community extends DSpaceObject implements ChildHALResource { static type = COMMUNITY; - /** - * A string representing the unique handle of this Community - */ - @autoserialize - handle: string; - /** * The {@link HALLink}s for this Community */ @@ -64,6 +58,13 @@ export class Community extends DSpaceObject implements ChildHALResource { @link(COMMUNITY, false) parentCommunity?: Observable>; + /** + * A string representing the unique handle of this Community + */ + get handle(): string { + return this.firstMetadataValue('dc.identifier.uri'); + } + /** * The introductory text of this Community * Corresponds to the metadata field dc.description diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index 4699a7977b..3613a143bd 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -13,4 +13,6 @@ export enum Context { EntitySearchModal = 'EntitySearchModal', AdminSearch = 'adminSearch', AdminWorkflowSearch = 'adminWorkflowSearch', + SideBarSearchModal = 'sideBarSearchModal', + SideBarSearchModalCurrent = 'sideBarSearchModalCurrent', } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 5d8d3609a2..78fef701a1 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -76,6 +76,10 @@ export const getSucceededRemoteWithNotEmptyData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => rd.hasSucceeded && isNotEmpty(rd.payload))); +export const getSucceededOrNoContentResponse = () => + (source: Observable>): Observable> => + source.pipe(find((rd: RemoteData) => rd.hasSucceeded || rd.hasNoContent)); + /** * Get the first successful remotely retrieved object * diff --git a/src/app/core/shared/process-output.resource-type.ts b/src/app/core/shared/process-output.resource-type.ts new file mode 100644 index 0000000000..2e707d0bda --- /dev/null +++ b/src/app/core/shared/process-output.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ProcessOutput + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const PROCESS_OUTPUT_TYPE = new ResourceType('processOutput'); diff --git a/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component.spec.ts new file mode 100644 index 0000000000..b9593e7612 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component.spec.ts @@ -0,0 +1,42 @@ +import { Item } from '../../../../../core/shared/item.model'; +import { Collection } from '../../../../../core/shared/collection.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec'; +import { JournalIssueSidebarSearchListElementComponent } from './journal-issue-sidebar-search-list-element.component'; + +const object = Object.assign(new ItemSearchResult(), { + indexableObject: Object.assign(new Item(), { + id: 'test-item', + metadata: { + 'dc.title': [ + { + value: 'title' + } + ], + 'publicationvolume.volumeNumber': [ + { + value: '5' + } + ], + 'publicationissue.issueNumber': [ + { + value: '7' + } + ] + } + }) +}); +const parent = Object.assign(new Collection(), { + id: 'test-collection', + metadata: { + 'dc.title': [ + { + value: 'parent title' + } + ] + } +}); + +describe('JournalIssueSidebarSearchListElementComponent', + createSidebarSearchListElementTests(JournalIssueSidebarSearchListElementComponent, object, parent, 'parent title', 'title', '5 - 7') +); diff --git a/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component.ts b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component.ts new file mode 100644 index 0000000000..84764e9e60 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component.ts @@ -0,0 +1,39 @@ +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { Component } from '@angular/core'; +import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { isNotEmpty } from '../../../../../shared/empty.util'; + +@listableObjectComponent('JournalIssueSearchResult', ViewMode.ListElement, Context.SideBarSearchModal) +@listableObjectComponent('JournalIssueSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent) +@Component({ + selector: 'ds-journal-issue-sidebar-search-list-element', + templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html' +}) +/** + * Component displaying a list element for a {@link ItemSearchResult} of type "JournalIssue" within the context of + * a sidebar search modal + */ +export class JournalIssueSidebarSearchListElementComponent extends SidebarSearchListElementComponent { + /** + * Get the description of the Journal Issue by returning its volume number(s) and/or issue number(s) + */ + getDescription(): string { + const volumeNumbers = this.allMetadataValues(['publicationvolume.volumeNumber']); + const issueNumbers = this.allMetadataValues(['publicationissue.issueNumber']); + let description = ''; + if (isNotEmpty(volumeNumbers)) { + description += volumeNumbers.join(', '); + } + if (isNotEmpty(description) && isNotEmpty(issueNumbers)) { + description += ' - '; + } + if (isNotEmpty(issueNumbers)) { + description += issueNumbers.join(', '); + } + return this.undefinedIfEmpty(description); + } +} diff --git a/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component.spec.ts new file mode 100644 index 0000000000..eca1775c7d --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component.spec.ts @@ -0,0 +1,45 @@ +import { Item } from '../../../../../core/shared/item.model'; +import { Collection } from '../../../../../core/shared/collection.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec'; +import { JournalVolumeSidebarSearchListElementComponent } from './journal-volume-sidebar-search-list-element.component'; + +const object = Object.assign(new ItemSearchResult(), { + indexableObject: Object.assign(new Item(), { + id: 'test-item', + metadata: { + 'dc.title': [ + { + value: 'title' + } + ], + 'journal.title': [ + { + value: 'journal title' + } + ], + 'publicationvolume.volumeNumber': [ + { + value: '1' + }, + { + value: '2' + } + ] + } + }) +}); +const parent = Object.assign(new Collection(), { + id: 'test-collection', + metadata: { + 'dc.title': [ + { + value: 'parent title' + } + ] + } +}); + +describe('JournalVolumeSidebarSearchListElementComponent', + createSidebarSearchListElementTests(JournalVolumeSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'journal title (1) (2)') +); diff --git a/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component.ts b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component.ts new file mode 100644 index 0000000000..7af5487f40 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component.ts @@ -0,0 +1,39 @@ +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { Component } from '@angular/core'; +import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { isNotEmpty } from '../../../../../shared/empty.util'; + +@listableObjectComponent('JournalVolumeSearchResult', ViewMode.ListElement, Context.SideBarSearchModal) +@listableObjectComponent('JournalVolumeSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent) +@Component({ + selector: 'ds-journal-volume-sidebar-search-list-element', + templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html' +}) +/** + * Component displaying a list element for a {@link ItemSearchResult} of type "JournalVolume" within the context of + * a sidebar search modal + */ +export class JournalVolumeSidebarSearchListElementComponent extends SidebarSearchListElementComponent { + /** + * Get the description of the Journal Volume by returning the journal title and volume number(s) (between parentheses) + */ + getDescription(): string { + const titles = this.allMetadataValues(['journal.title']); + const numbers = this.allMetadataValues(['publicationvolume.volumeNumber']); + let description = ''; + if (isNotEmpty(titles)) { + description += titles.join(', '); + } + if (isNotEmpty(numbers)) { + if (isNotEmpty(description)) { + description += ' '; + } + description += numbers.map((n) => `(${n})`).join(' '); + } + return this.undefinedIfEmpty(description); + } +} diff --git a/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component.spec.ts new file mode 100644 index 0000000000..a47dbf0e2e --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component.spec.ts @@ -0,0 +1,40 @@ +import { Item } from '../../../../../core/shared/item.model'; +import { Collection } from '../../../../../core/shared/collection.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec'; +import { JournalSidebarSearchListElementComponent } from './journal-sidebar-search-list-element.component'; + +const object = Object.assign(new ItemSearchResult(), { + indexableObject: Object.assign(new Item(), { + id: 'test-item', + metadata: { + 'dc.title': [ + { + value: 'title' + } + ], + 'creativeworkseries.issn': [ + { + value: '1234' + }, + { + value: '5678' + } + ] + } + }) +}); +const parent = Object.assign(new Collection(), { + id: 'test-collection', + metadata: { + 'dc.title': [ + { + value: 'parent title' + } + ] + } +}); + +describe('JournalSidebarSearchListElementComponent', + createSidebarSearchListElementTests(JournalSidebarSearchListElementComponent, object, parent, 'parent title', 'title', '1234, 5678') +); diff --git a/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component.ts b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component.ts new file mode 100644 index 0000000000..4a26b293d9 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component.ts @@ -0,0 +1,32 @@ +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { Component } from '@angular/core'; +import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { isNotEmpty } from '../../../../../shared/empty.util'; + +@listableObjectComponent('JournalSearchResult', ViewMode.ListElement, Context.SideBarSearchModal) +@listableObjectComponent('JournalSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent) +@Component({ + selector: 'ds-journal-sidebar-search-list-element', + templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html' +}) +/** + * Component displaying a list element for a {@link ItemSearchResult} of type "Journal" within the context of + * a sidebar search modal + */ +export class JournalSidebarSearchListElementComponent extends SidebarSearchListElementComponent { + /** + * Get the description of the Journal by returning its ISSN(s) + */ + getDescription(): string { + const issns = this.allMetadataValues(['creativeworkseries.issn']); + let description = ''; + if (isNotEmpty(issns)) { + description += issns.join(', '); + } + return this.undefinedIfEmpty(description); + } +} diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index cdfa6293c4..c5eb3ba59e 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -1,6 +1,11 @@ -

- {{'journalissue.page.titleprefix' | translate}} -

+
+

+ {{'journalissue.page.titleprefix' | translate}} +

+
+ +
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 5df1997f0b..38a201df16 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -1,6 +1,11 @@ -

- {{'journalvolume.page.titleprefix' | translate}} -

+
+

+ {{'journalvolume.page.titleprefix' | translate}} +

+
+ +
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index d8d32df04a..874f748049 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -1,6 +1,11 @@ -

- {{'journal.page.titleprefix' | translate}} -

+
+

+ {{'journal.page.titleprefix' | translate}} +

+
+ +
+
diff --git a/src/app/entity-groups/journal-entities/journal-entities.module.ts b/src/app/entity-groups/journal-entities/journal-entities.module.ts index d00eae1e54..11ce6c4c2a 100644 --- a/src/app/entity-groups/journal-entities/journal-entities.module.ts +++ b/src/app/entity-groups/journal-entities/journal-entities.module.ts @@ -18,6 +18,9 @@ import { JournalIssueSearchResultListElementComponent } from './item-list-elemen import { JournalVolumeSearchResultListElementComponent } from './item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component'; import { JournalIssueSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component'; import { JournalVolumeSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component'; +import { JournalVolumeSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component'; +import { JournalIssueSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component'; +import { JournalSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component'; const ENTRY_COMPONENTS = [ JournalComponent, @@ -34,7 +37,10 @@ const ENTRY_COMPONENTS = [ JournalVolumeSearchResultListElementComponent, JournalIssueSearchResultGridElementComponent, JournalVolumeSearchResultGridElementComponent, - JournalSearchResultGridElementComponent + JournalSearchResultGridElementComponent, + JournalVolumeSidebarSearchListElementComponent, + JournalIssueSidebarSearchListElementComponent, + JournalSidebarSearchListElementComponent, ]; @NgModule({ diff --git a/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html index 290635ea27..550fa0d12e 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/person/person-list-element.component.html @@ -1 +1,6 @@ - + + diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html index 5566ba656b..a07d9e91af 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html @@ -2,7 +2,7 @@ + [innerHTML]="name"> diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.ts index b4b4621261..84f5586876 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.ts +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.ts @@ -15,4 +15,10 @@ import { Item } from '../../../../../core/shared/item.model'; * The component for displaying a list element for an item search result of the type Person */ export class PersonSearchResultListElementComponent extends SearchResultListElementComponent { + + get name() { + return this.value ? + this.value : + this.firstMetadataValue('person.familyName') + ', ' + this.firstMetadataValue('person.givenName'); + } } diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.spec.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.spec.ts new file mode 100644 index 0000000000..a271273a1b --- /dev/null +++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.spec.ts @@ -0,0 +1,37 @@ +import { Item } from '../../../../../core/shared/item.model'; +import { Collection } from '../../../../../core/shared/collection.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec'; +import { OrgUnitSidebarSearchListElementComponent } from './org-unit-sidebar-search-list-element.component'; + +const object = Object.assign(new ItemSearchResult(), { + indexableObject: Object.assign(new Item(), { + id: 'test-item', + metadata: { + 'organization.legalName': [ + { + value: 'title' + } + ], + 'dc.description': [ + { + value: 'description' + } + ] + } + }) +}); +const parent = Object.assign(new Collection(), { + id: 'test-collection', + metadata: { + 'dc.title': [ + { + value: 'parent title' + } + ] + } +}); + +describe('OrgUnitSidebarSearchListElementComponent', + createSidebarSearchListElementTests(OrgUnitSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description') +); diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.ts new file mode 100644 index 0000000000..17e9790593 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component.ts @@ -0,0 +1,33 @@ +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { Component } from '@angular/core'; +import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component'; +import { Item } from '../../../../../core/shared/item.model'; + +@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SideBarSearchModal) +@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent) +@Component({ + selector: 'ds-org-unit-sidebar-search-list-element', + templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html' +}) +/** + * Component displaying a list element for a {@link ItemSearchResult} of type "OrgUnit" within the context of + * a sidebar search modal + */ +export class OrgUnitSidebarSearchListElementComponent extends SidebarSearchListElementComponent { + /** + * Get the title of the Org Unit by returning its legal name + */ + getTitle(): string { + return this.firstMetadataValue('organization.legalName'); + } + + /** + * Get the description of the Org Unit by returning its dc.description + */ + getDescription(): string { + return this.firstMetadataValue('dc.description'); + } +} diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.spec.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.spec.ts new file mode 100644 index 0000000000..e93dd78636 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.spec.ts @@ -0,0 +1,45 @@ +import { Item } from '../../../../../core/shared/item.model'; +import { Collection } from '../../../../../core/shared/collection.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec'; +import { PersonSidebarSearchListElementComponent } from './person-sidebar-search-list-element.component'; +import { TranslateService } from '@ngx-translate/core'; + +const object = Object.assign(new ItemSearchResult(), { + indexableObject: Object.assign(new Item(), { + id: 'test-item', + metadata: { + 'person.familyName': [ + { + value: 'family name' + } + ], + 'person.givenName': [ + { + value: 'given name' + } + ], + 'person.jobTitle': [ + { + value: 'job title' + } + ] + } + }) +}); +const parent = Object.assign(new Collection(), { + id: 'test-collection', + metadata: { + 'dc.title': [ + { + value: 'parent title' + } + ] + } +}); + +describe('PersonSidebarSearchListElementComponent', + createSidebarSearchListElementTests(PersonSidebarSearchListElementComponent, object, parent, 'parent title', 'family name, given name', 'job title', [ + { provide: TranslateService, useValue: jasmine.createSpyObj('translate', { instant: '' }) } + ]) +); diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.ts new file mode 100644 index 0000000000..ade080f2e2 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.ts @@ -0,0 +1,60 @@ +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { Component } from '@angular/core'; +import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { isNotEmpty } from '../../../../../shared/empty.util'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { TranslateService } from '@ngx-translate/core'; + +@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SideBarSearchModal) +@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent) +@Component({ + selector: 'ds-person-sidebar-search-list-element', + templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html' +}) +/** + * Component displaying a list element for a {@link ItemSearchResult} of type "Person" within the context of + * a sidebar search modal + */ +export class PersonSidebarSearchListElementComponent extends SidebarSearchListElementComponent { + constructor(protected truncatableService: TruncatableService, + protected linkService: LinkService, + protected translateService: TranslateService) { + super(truncatableService, linkService); + } + + /** + * Get the title of the Person by returning a combination of its family name and given name (or "No name found") + */ + getTitle(): string { + const familyName = this.firstMetadataValue('person.familyName'); + const givenName = this.firstMetadataValue('person.givenName'); + let title = ''; + if (isNotEmpty(familyName)) { + title = familyName; + } + if (isNotEmpty(title)) { + title += ', '; + } + if (isNotEmpty(givenName)) { + title += givenName; + } + return this.defaultIfEmpty(title, this.translateService.instant('person.listelement.no-title')); + } + + /** + * Get the description of the Person by returning its job title(s) + */ + getDescription(): string { + const titles = this.allMetadataValues(['person.jobTitle']); + let description = ''; + if (isNotEmpty(titles)) { + description += titles.join(', '); + } + return this.undefinedIfEmpty(description); + } +} diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component.spec.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component.spec.ts new file mode 100644 index 0000000000..ec0eb0e7bb --- /dev/null +++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component.spec.ts @@ -0,0 +1,32 @@ +import { Item } from '../../../../../core/shared/item.model'; +import { Collection } from '../../../../../core/shared/collection.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec'; +import { ProjectSidebarSearchListElementComponent } from './project-sidebar-search-list-element.component'; + +const object = Object.assign(new ItemSearchResult(), { + indexableObject: Object.assign(new Item(), { + id: 'test-item', + metadata: { + 'dc.title': [ + { + value: 'title' + } + ] + } + }) +}); +const parent = Object.assign(new Collection(), { + id: 'test-collection', + metadata: { + 'dc.title': [ + { + value: 'parent title' + } + ] + } +}); + +describe('ProjectSidebarSearchListElementComponent', + createSidebarSearchListElementTests(ProjectSidebarSearchListElementComponent, object, parent, 'parent title', 'title', undefined) +); diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component.ts new file mode 100644 index 0000000000..6fbb9d26fc --- /dev/null +++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component.ts @@ -0,0 +1,26 @@ +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { Component } from '@angular/core'; +import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component'; +import { Item } from '../../../../../core/shared/item.model'; + +@listableObjectComponent('ProjectSearchResult', ViewMode.ListElement, Context.SideBarSearchModal) +@listableObjectComponent('ProjectSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent) +@Component({ + selector: 'ds-project-sidebar-search-list-element', + templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html' +}) +/** + * Component displaying a list element for a {@link ItemSearchResult} of type "Project" within the context of + * a sidebar search modal + */ +export class ProjectSidebarSearchListElementComponent extends SidebarSearchListElementComponent { + /** + * Projects currently don't support a description + */ + getDescription(): string { + return undefined; + } +} diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 784000b446..4d90b85133 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -1,6 +1,11 @@ -

- {{'orgunit.page.titleprefix' | translate}} -

+
+

+ {{'orgunit.page.titleprefix' | translate}} +

+
+ +
+
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index af71d5a1c6..2d751eeabb 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -1,6 +1,11 @@ -

- {{'person.page.titleprefix' | translate}} -

+
+

+ {{'person.page.titleprefix' | translate}} +

+
+ +
+
diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index 6c4a0bab01..4d681bb172 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -1,6 +1,11 @@ -

- {{'project.page.titleprefix' | translate}} -

+
+

+ {{'project.page.titleprefix' | translate}} +

+
+ +
+
diff --git a/src/app/entity-groups/research-entities/research-entities.module.ts b/src/app/entity-groups/research-entities/research-entities.module.ts index cef3b4539b..1f50ab830a 100644 --- a/src/app/entity-groups/research-entities/research-entities.module.ts +++ b/src/app/entity-groups/research-entities/research-entities.module.ts @@ -26,6 +26,9 @@ import { NameVariantModalComponent } from './submission/name-variant-modal/name- import { OrgUnitInputSuggestionsComponent } from './submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component'; import { OrgUnitSearchResultListSubmissionElementComponent } from './submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component'; import { ExternalSourceEntryListSubmissionElementComponent } from './submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component'; +import { OrgUnitSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component'; +import { PersonSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component'; +import { ProjectSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component'; const ENTRY_COMPONENTS = [ OrgUnitComponent, @@ -50,7 +53,10 @@ const ENTRY_COMPONENTS = [ NameVariantModalComponent, OrgUnitSearchResultListSubmissionElementComponent, OrgUnitInputSuggestionsComponent, - ExternalSourceEntryListSubmissionElementComponent + ExternalSourceEntryListSubmissionElementComponent, + OrgUnitSidebarSearchListElementComponent, + PersonSidebarSearchListElementComponent, + ProjectSidebarSearchListElementComponent, ]; @NgModule({ diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts index 9541ff334c..3183d92b07 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts @@ -61,6 +61,7 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu } select(value) { + this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value); this.selectableListService.isObjectSelected(this.listID, this.object) .pipe(take(1)) .subscribe((selected) => { @@ -68,7 +69,6 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu this.selectableListService.selectSingle(this.listID, this.object); } }); - this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value); } selectCustom(value) { diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 9cb1f1e6af..d59f93254e 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -17,7 +17,7 @@ {{getFileName(file)}} - ({{(file?.sizeBytes) | dsFileSize }}) + ({{(file?.sizeBytes) | dsFileSize }})
@@ -34,9 +34,20 @@
{{ process.processStatus }}
- - - + + + +
{{ (outputLogs$ | async) }}
+

+ {{ 'process.detail.logs.none' | translate }} +

+
- {{'process.detail.back' | translate}} +
diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index 2006609f01..94282f886e 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -1,9 +1,22 @@ +import { HttpClient } from '@angular/common/http'; +import { AuthService } from '../../core/auth/auth.service'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; import { ProcessDetailComponent } from './process-detail.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { + async, + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + flush, + flushMicrotasks, + TestBed, + tick +} from '@angular/core/testing'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ProcessDetailFieldComponent } from './process-detail-field/process-detail-field.component'; import { Process } from '../processes/process.model'; import { ActivatedRoute } from '@angular/router'; @@ -15,7 +28,6 @@ import { ProcessDataService } from '../../core/data/processes/process-data.servi import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; -import { AuthService } from '../../core/auth/auth.service'; describe('ProcessDetailComponent', () => { let component: ProcessDetailComponent; @@ -23,16 +35,22 @@ describe('ProcessDetailComponent', () => { let processService: ProcessDataService; let nameService: DSONameService; + let bitstreamDataService: BitstreamDataService; + let httpClient: HttpClient; let authService: AuthService; let process: Process; let fileName: string; let files: Bitstream[]; + let processOutput; + function init() { + processOutput = 'Process Started' process = Object.assign(new Process(), { processId: 1, scriptName: 'script-name', + processStatus: 'COMPLETED', parameters: [ { name: '-f', @@ -42,7 +60,15 @@ describe('ProcessDetailComponent', () => { name: '-i', value: 'identifier' } - ] + ], + _links: { + self: { + href: 'https://rest.api/processes/1' + }, + output: { + href: 'https://rest.api/processes/1/output' + } + } }); fileName = 'fake-file-name'; files = [ @@ -61,12 +87,24 @@ describe('ProcessDetailComponent', () => { } }) ]; + const logBitstream = Object.assign(new Bitstream(), { + id: 'output.log', + _links: { + content: { href: 'log-selflink' } + } + }); processService = jasmine.createSpyObj('processService', { getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)) }); + bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findByHref: createSuccessfulRemoteDataObject$(logBitstream) + }); nameService = jasmine.createSpyObj('nameService', { getName: fileName }); + httpClient = jasmine.createSpyObj('httpClient', { + get: observableOf(processOutput) + }); authService = jasmine.createSpyObj('authService', { isAuthenticated: observableOf(true), setRedirectUrl: {} @@ -79,27 +117,42 @@ describe('ProcessDetailComponent', () => { declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: ActivatedRoute, useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } }, + { + provide: ActivatedRoute, + useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } + }, { provide: ProcessDataService, useValue: processService }, + { provide: BitstreamDataService, useValue: bitstreamDataService }, { provide: DSONameService, useValue: nameService }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: HttpClient, useValue: httpClient }, { provide: AuthService, useValue: authService }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ProcessDetailComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); + afterEach(fakeAsync(() => { + TestBed.resetTestingModule(); + fixture.destroy(); + flush(); + flushMicrotasks(); + discardPeriodicTasks(); + component = null; + })); it('should display the script\'s name', () => { + fixture.detectChanges(); const name = fixture.debugElement.query(By.css('#process-name')).nativeElement; expect(name.textContent).toContain(process.scriptName); }); it('should display the process\'s parameters', () => { + fixture.detectChanges(); const args = fixture.debugElement.query(By.css('#process-arguments')).nativeElement; process.parameters.forEach((param) => { expect(args.textContent).toContain(`${param.name} ${param.value}`) @@ -107,8 +160,57 @@ describe('ProcessDetailComponent', () => { }); it('should display the process\'s output files', () => { + fixture.detectChanges(); const processFiles = fixture.debugElement.query(By.css('#process-files')).nativeElement; expect(processFiles.textContent).toContain(fileName); }); + describe('if press show output logs', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'showProcessOutputLogs').and.callThrough(); + fixture.detectChanges(); + + const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton')); + showOutputButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + })); + it('should trigger showProcessOutputLogs', () => { + expect(component.showProcessOutputLogs).toHaveBeenCalled(); + }); + it('should display the process\'s output logs', () => { + fixture.detectChanges(); + const outputProcess = fixture.debugElement.query(By.css('#process-output pre')); + expect(outputProcess.nativeElement.textContent).toContain(processOutput); + }); + }); + + describe('if press show output logs and process has no output logs', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(httpClient, 'get').and.returnValue(observableOf(null)); + fixture = TestBed.createComponent(ProcessDetailComponent); + component = fixture.componentInstance; + spyOn(component, 'showProcessOutputLogs').and.callThrough(); + fixture.detectChanges(); + const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton')); + showOutputButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should not display the process\'s output logs', () => { + const outputProcess = fixture.debugElement.query(By.css('#process-output pre')); + expect(outputProcess).toBeNull(); + }); + it('should display message saying there are no output logs', () => { + const noOutputProcess = fixture.debugElement.query(By.css('#no-output-logs-message')).nativeElement; + expect(noOutputProcess).toBeDefined(); + }); + }); + }); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index dd89724f0e..53dd6dd921 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -1,16 +1,23 @@ -import { Component, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Component, NgZone, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { Observable } from 'rxjs/internal/Observable'; -import { RemoteData } from '../../core/data/remote-data'; -import { Process } from '../processes/process.model'; -import { map, switchMap } from 'rxjs/operators'; -import { getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; -import { AlertType } from '../../shared/alert/aletr-type'; -import { ProcessDataService } from '../../core/data/processes/process-data.service'; -import { PaginatedList } from '../../core/data/paginated-list'; -import { Bitstream } from '../../core/shared/bitstream.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { finalize, map, mergeMap, switchMap, take, tap } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { ProcessDataService } from '../../core/data/processes/process-data.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { AlertType } from '../../shared/alert/aletr-type'; +import { hasValue } from '../../shared/empty.util'; +import { ProcessStatus } from '../processes/process-status.model'; +import { Process } from '../processes/process.model'; @Component({ selector: 'ds-process-detail', @@ -37,11 +44,33 @@ export class ProcessDetailComponent implements OnInit { */ filesRD$: Observable>>; + /** + * File link that contain the output logs with auth token + */ + outputLogFileUrl$: Observable; + + /** + * The Process's Output logs + */ + outputLogs$: Observable; + + /** + * Boolean on whether or not to show the output logs + */ + showOutputLogs; + /** + * When it's retrieving the output logs from backend, to show loading component + */ + retrievingOutputLogs$: BehaviorSubject; + constructor(protected route: ActivatedRoute, protected router: Router, protected processService: ProcessDataService, + protected bitstreamDataService: BitstreamDataService, protected nameService: DSONameService, - protected authService: AuthService) { + private zone: NgZone, + protected authService: AuthService, + protected http: HttpClient) { } /** @@ -49,8 +78,12 @@ export class ProcessDetailComponent implements OnInit { * Display a 404 if the process doesn't exist */ ngOnInit(): void { + this.showOutputLogs = false; + this.retrievingOutputLogs$ = new BehaviorSubject(false); this.processRD$ = this.route.data.pipe( - map((data) => data.process as RemoteData), + map((data) => { + return data.process as RemoteData + }), redirectOn4xx(this.router, this.authService) ); @@ -65,7 +98,68 @@ export class ProcessDetailComponent implements OnInit { * @param bitstream */ getFileName(bitstream: Bitstream) { - return this.nameService.getName(bitstream); + return bitstream instanceof DSpaceObject ? this.nameService.getName(bitstream) : 'unknown'; + } + + /** + * Retrieves the process logs, while setting the loading subject to true. + * Sets the outputLogs when retrieved and sets the showOutputLogs boolean to show them and hide the button. + */ + showProcessOutputLogs() { + this.retrievingOutputLogs$.next(true); + this.zone.runOutsideAngular(() => { + const processOutputRD$: Observable> = this.processRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((process: Process) => { + return this.bitstreamDataService.findByHref(process._links.output.href); + }) + ); + this.outputLogFileUrl$ = processOutputRD$.pipe( + tap((processOutputFileRD: RemoteData) => { + if (processOutputFileRD.statusCode === 204) { + this.zone.run(() => this.retrievingOutputLogs$.next(false)); + this.showOutputLogs = true; + } + }), + getFirstSucceededRemoteDataPayload(), + mergeMap((processOutput: Bitstream) => { + const url = processOutput._links.content.href; + return this.authService.getShortlivedToken().pipe(take(1), + map((token: string) => { + return hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url; + })); + }) + ) + }); + this.outputLogs$ = this.outputLogFileUrl$.pipe(take(1), + mergeMap((url: string) => { + return this.getTextFile(url); + }), + finalize(() => this.zone.run(() => this.retrievingOutputLogs$.next(false))), + ); + this.outputLogs$.pipe(take(1)).subscribe(); + } + + getTextFile(filename: string): Observable { + // The Observable returned by get() is of type Observable + // because a text response was specified. + // There's no need to pass a type parameter to get(). + return this.http.get(filename, { responseType: 'text' }) + .pipe( + finalize(() => { + this.showOutputLogs = true; + }), + ); + } + + /** + * Whether or not the given process has Completed or Failed status + * @param process Process to check if completed or failed + */ + isProcessFinished(process: Process): boolean { + return (hasValue(process) && hasValue(process.processStatus) && + (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString() + || process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString())); } } diff --git a/src/app/process-page/processes/process.model.ts b/src/app/process-page/processes/process.model.ts index 85de5337e7..74bb82b890 100644 --- a/src/app/process-page/processes/process.model.ts +++ b/src/app/process-page/processes/process.model.ts @@ -1,3 +1,5 @@ +import { Bitstream } from '../../core/shared/bitstream.model'; +import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-type'; import { ProcessStatus } from './process-status.model'; import { ProcessParameter } from './process-parameter.model'; import { CacheableObject } from '../../core/cache/object-cache.reducer'; @@ -85,4 +87,11 @@ export class Process implements CacheableObject { */ @link(SCRIPT) script?: Observable>; + + /** + * The output logs created by this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(PROCESS_OUTPUT_TYPE) + output?: Observable>; } diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 8d78539bab..418626e4d1 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -177,7 +177,7 @@ describe('ProfilePageComponent', () => { component.setPasswordValue('testest'); component.setInvalid(false); - operations = [{op: 'replace', path: '/password', value: 'testest'}]; + operations = [{op: 'add', path: '/password', value: 'testest'}]; result = component.updateSecurity(); }); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index bc06c49f81..4ae644a633 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -120,7 +120,7 @@ export class ProfilePageComponent implements OnInit { this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general')); } if (!this.invalidSecurity && passEntered) { - const operation = Object.assign({op: 'replace', path: '/password', value: this.password}); + const operation = Object.assign({op: 'add', path: '/password', value: this.password}); this.epersonService.patch(this.currentUser, [operation]).subscribe((response: RestResponse) => { if (response.isSuccessful) { this.notificationsService.success( 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 780589d0c5..d7827957a1 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 @@ -2,7 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { CommunityDataService } from '../../../core/data/community-data.service'; import { RouteService } from '../../../core/services/route.service'; import { Router } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { Community } from '../../../core/shared/community.model'; import { SharedModule } from '../../shared.module'; @@ -12,12 +12,14 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { CreateComColPageComponent } from './create-comcol-page.component'; import { - createFailedRemoteDataObject$, + createFailedRemoteDataObject$, createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { ComColDataService } from '../../../core/data/comcol-data.service'; import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { RequestService } from '../../../core/data/request.service'; +import {getTestScheduler} from 'jasmine-marbles'; describe('CreateComColPageComponent', () => { let comp: CreateComColPageComponent; @@ -29,9 +31,12 @@ describe('CreateComColPageComponent', () => { let community; let newCommunity; + let parentCommunity; let communityDataServiceStub; let routeServiceStub; let routerStub; + let requestServiceStub; + let scheduler; const logoEndpoint = 'rest/api/logo/endpoint'; @@ -41,7 +46,18 @@ describe('CreateComColPageComponent', () => { metadata: [{ key: 'dc.title', value: 'test community' - }] + }], + _links: {} + }); + + parentCommunity = Object.assign(new Community(), { + uuid: 'a20da287-e174-466a-9926-f66as300d399', + id: 'a20da287-e174-466a-9926-f66as300d399', + metadata: [{ + key: 'dc.title', + value: 'parent community' + }], + _links: {} }); newCommunity = Object.assign(new Community(), { @@ -49,7 +65,8 @@ describe('CreateComColPageComponent', () => { metadata: [{ key: 'dc.title', value: 'new community' - }] + }], + _links: {} }); communityDataServiceStub = { @@ -61,7 +78,9 @@ describe('CreateComColPageComponent', () => { }] })), create: (com, uuid?) => createSuccessfulRemoteDataObject$(newCommunity), - getLogoEndpoint: () => observableOf(logoEndpoint) + getLogoEndpoint: () => observableOf(logoEndpoint), + findByHref: () => null, + refreshCache: () => {return} }; routeServiceStub = { @@ -71,6 +90,10 @@ describe('CreateComColPageComponent', () => { navigate: (commands) => commands }; + requestServiceStub = jasmine.createSpyObj('RequestService', { + removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring'), + }); + } beforeEach(async(() => { @@ -82,7 +105,8 @@ describe('CreateComColPageComponent', () => { { provide: CommunityDataService, useValue: communityDataServiceStub }, { provide: RouteService, useValue: routeServiceStub }, { provide: Router, useValue: routerStub }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() } + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: RequestService, useValue: requestServiceStub} ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -97,6 +121,7 @@ describe('CreateComColPageComponent', () => { communityDataService = (comp as any).communityDataService; routeService = (comp as any).routeService; router = (comp as any).router; + scheduler = getTestScheduler(); }); describe('onSubmit', () => { @@ -111,6 +136,7 @@ describe('CreateComColPageComponent', () => { value: 'test' }] }), + _links: {}, uploader: { options: { url: '' @@ -123,19 +149,23 @@ describe('CreateComColPageComponent', () => { }; }); - it('should navigate when successful', () => { + it('should navigate and refresh cache when successful', () => { spyOn(router, 'navigate'); - comp.onSubmit(data); - fixture.detectChanges(); + spyOn((dsoDataService as any), 'refreshCache') + scheduler.schedule(() => comp.onSubmit(data)); + scheduler.flush(); expect(router.navigate).toHaveBeenCalled(); + expect((dsoDataService as any).refreshCache).toHaveBeenCalled(); }); - it('should not navigate on failure', () => { + it('should neither navigate nor refresh cache on failure', () => { spyOn(router, 'navigate'); spyOn(dsoDataService, 'create').and.returnValue(createFailedRemoteDataObject$(newCommunity)); - comp.onSubmit(data); - fixture.detectChanges(); + spyOn(dsoDataService, 'refreshCache') + scheduler.schedule(() => comp.onSubmit(data)); + scheduler.flush(); expect(router.navigate).not.toHaveBeenCalled(); + expect((dsoDataService as any).refreshCache).not.toHaveBeenCalled(); }); }); @@ -148,6 +178,7 @@ describe('CreateComColPageComponent', () => { value: 'test' }] }), + _links: {}, uploader: { options: { url: '' @@ -164,21 +195,21 @@ describe('CreateComColPageComponent', () => { it('should not navigate', () => { spyOn(router, 'navigate'); - comp.onSubmit(data); - fixture.detectChanges(); + scheduler.schedule(() => comp.onSubmit(data)); + scheduler.flush(); expect(router.navigate).not.toHaveBeenCalled(); }); it('should set the uploader\'s url to the logo\'s endpoint', () => { - comp.onSubmit(data); - fixture.detectChanges(); + scheduler.schedule(() => comp.onSubmit(data)); + scheduler.flush(); expect(data.uploader.options.url).toEqual(logoEndpoint); }); it('should call the uploader\'s uploadAll', () => { spyOn(data.uploader, 'uploadAll'); - comp.onSubmit(data); - fixture.detectChanges(); + scheduler.schedule(() => comp.onSubmit(data)); + scheduler.flush(); expect(data.uploader.uploadAll).toHaveBeenCalled(); }); }); 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 4a7cd9afb1..49c27591d1 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 @@ -2,18 +2,21 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; +import {flatMap, take} from 'rxjs/operators'; import { ComColDataService } from '../../../core/data/comcol-data.service'; import { CommunityDataService } from '../../../core/data/community-data.service'; import { RemoteData } from '../../../core/data/remote-data'; import { RouteService } from '../../../core/services/route.service'; import { Community } from '../../../core/shared/community.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { + getFirstSucceededRemoteDataPayload, +} from '../../../core/shared/operators'; import { ResourceType } from '../../../core/shared/resource-type'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../empty.util'; +import {hasValue, isNotEmpty, isNotUndefined} from '../../empty.util'; import { NotificationsService } from '../../notifications/notifications.service'; import { RequestParam } from '../../../core/cache/models/request-param.model'; +import {RequestService} from '../../../core/data/request.service'; /** * Component representing the create page for communities and collections @@ -54,7 +57,8 @@ export class CreateComColPageComponent implements protected routeService: RouteService, protected router: Router, protected notificationsService: NotificationsService, - protected translate: TranslateService + protected translate: TranslateService, + protected requestService: RequestService ) { } @@ -76,25 +80,29 @@ export class CreateComColPageComponent implements const dso = event.dso; const uploader = event.uploader; - this.parentUUID$.pipe(take(1)).subscribe((uuid: string) => { + this.parentUUID$.pipe( + take(1), + flatMap((uuid: string) => { const params = uuid ? [new RequestParam('parent', uuid)] : []; - this.dsoDataService.create(dso, ...params) - .pipe(getSucceededRemoteData()) - .subscribe((dsoRD: RemoteData) => { - if (isNotUndefined(dsoRD)) { - this.newUUID = dsoRD.payload.uuid; - if (uploader.queue.length > 0) { - this.dsoDataService.getLogoEndpoint(this.newUUID).pipe(take(1)).subscribe((href: string) => { - uploader.options.url = href; - uploader.uploadAll(); - }); - } else { - this.navigateToNewPage(); - } - this.notificationsService.success(null, this.translate.get(this.type.value + '.create.notifications.success')); + return this.dsoDataService.create(dso, ...params) + .pipe(getFirstSucceededRemoteDataPayload() + ) + })) + .subscribe((dsoRD: TDomain) => { + if (isNotUndefined(dsoRD)) { + this.newUUID = dsoRD.uuid; + if (uploader.queue.length > 0) { + this.dsoDataService.getLogoEndpoint(this.newUUID).pipe(take(1)).subscribe((href: string) => { + uploader.options.url = href; + uploader.uploadAll(); + }); + } else { + this.navigateToNewPage(); } - }); - }); + this.dsoDataService.refreshCache(dsoRD); + } + this.notificationsService.success(null, this.translate.get(this.type.value + '.create.notifications.success')); + }); } /** @@ -105,5 +113,4 @@ export class CreateComColPageComponent implements this.router.navigate([this.frontendURL + this.newUUID]); } } - } 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 e791f41d56..6669f4c395 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 @@ -1,7 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { CommunityDataService } from '../../../core/data/community-data.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { Community } from '../../../core/shared/community.model'; import { SharedModule } from '../../shared.module'; @@ -13,6 +13,10 @@ 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'; +import {RequestService} from '../../../core/data/request.service'; +import {getTestScheduler} from 'jasmine-marbles'; +import {createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$} from '../../remote-data.utils'; +import {ComColDataService} from '../../../core/data/comcol-data.service'; describe('DeleteComColPageComponent', () => { let comp: DeleteComColPageComponent; @@ -22,9 +26,15 @@ describe('DeleteComColPageComponent', () => { let community; let newCommunity; + let parentCommunity; let routerStub; let routeStub; let notificationsService; + let translateServiceStub; + let requestServiceStub; + + let scheduler; + const validUUID = 'valid-uuid'; const invalidUUID = 'invalid-uuid'; const frontendURL = '/testType'; @@ -45,10 +55,21 @@ describe('DeleteComColPageComponent', () => { }] }); + parentCommunity = Object.assign(new Community(), { + uuid: 'a20da287-e174-466a-9926-f66as300d399', + id: 'a20da287-e174-466a-9926-f66as300d399', + metadata: [{ + key: 'dc.title', + value: 'parent community' + }] + }); + dsoDataService = jasmine.createSpyObj( 'dsoDataService', { - delete: observableOf({ isSuccessful: true }) + delete: observableOf({ isSuccessful: true }), + findByHref: jasmine.createSpy('findByHref'), + refreshCache: jasmine.createSpy('refreshCache') }); routerStub = { @@ -59,6 +80,14 @@ describe('DeleteComColPageComponent', () => { data: observableOf(community) }; + requestServiceStub = jasmine.createSpyObj('RequestService', { + removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring') + }); + + translateServiceStub = jasmine.createSpyObj('TranslateService', { + instant: jasmine.createSpy('instant') + }); + } beforeEach(async(() => { @@ -66,10 +95,12 @@ describe('DeleteComColPageComponent', () => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], providers: [ - { provide: DataService, useValue: dsoDataService }, + { provide: ComColDataService, useValue: dsoDataService }, { provide: Router, useValue: routerStub }, { provide: ActivatedRoute, useValue: routeStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: TranslateService, useValue: translateServiceStub}, + { provide: RequestService, useValue: requestServiceStub} ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -82,43 +113,63 @@ describe('DeleteComColPageComponent', () => { notificationsService = (comp as any).notifications; (comp as any).frontendURL = frontendURL; router = (comp as any).router; + scheduler = getTestScheduler(); }); describe('onConfirm', () => { let data1; let data2; beforeEach(() => { - data1 = Object.assign(new Community(), { - uuid: validUUID, - metadata: [{ - key: 'dc.title', - value: 'test' - }] - }); + data1 = { + dso: Object.assign(new Community(), { + uuid: validUUID, + metadata: [{ + key: 'dc.title', + value: 'test' + }] + }), + _links: {} + }; - data2 = Object.assign(new Community(), { - uuid: invalidUUID, - metadata: [{ - key: 'dc.title', - value: 'test' - }] - }); + data2 = { + dso: Object.assign(new Community(), { + uuid: invalidUUID, + metadata: [{ + key: 'dc.title', + value: 'test' + }] + }), + _links: {}, + uploader: { + options: { + url: '' + }, + queue: [], + /* tslint:disable:no-empty */ + uploadAll: () => {} + /* tslint:enable:no-empty */ + } + }; }); it('should show an error notification on failure', () => { (dsoDataService.delete as any).and.returnValue(observableOf({ isSuccessful: false })); spyOn(router, 'navigate'); - comp.onConfirm(data2); + scheduler.schedule(() => comp.onConfirm(data2)); + scheduler.flush(); fixture.detectChanges(); expect(notificationsService.error).toHaveBeenCalled(); + expect(dsoDataService.refreshCache).not.toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalled(); }); it('should show a success notification on success and navigate', () => { spyOn(router, 'navigate'); - comp.onConfirm(data1); + scheduler.schedule(() => comp.onConfirm(data1)); + scheduler.flush(); fixture.detectChanges(); expect(notificationsService.success).toHaveBeenCalled(); + expect(dsoDataService.refreshCache).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalled(); }); 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 d07d7be032..825fad495f 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,14 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RemoteData } from '../../../core/data/remote-data'; -import { first, map } from 'rxjs/operators'; -import { DataService } from '../../../core/data/data.service'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { NotificationsService } from '../../notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { RestResponse } from '../../../core/cache/response.models'; +import {Component, OnInit} from '@angular/core'; +import {Observable} from 'rxjs'; +import {ActivatedRoute, Router} from '@angular/router'; +import {RemoteData} from '../../../core/data/remote-data'; +import {first, map} from 'rxjs/operators'; +import {DSpaceObject} from '../../../core/shared/dspace-object.model'; +import {NotificationsService} from '../../notifications/notifications.service'; +import {TranslateService} from '@ngx-translate/core'; +import {RestResponse} from '../../../core/cache/response.models'; +import {RequestService} from '../../../core/data/request.service'; +import {ComColDataService} from '../../../core/data/comcol-data.service'; /** * Component representing the delete page for communities and collections @@ -27,11 +28,12 @@ export class DeleteComColPageComponent implements public dsoRD$: Observable>; public constructor( - protected dsoDataService: DataService, + protected dsoDataService: ComColDataService, protected router: Router, protected route: ActivatedRoute, protected notifications: NotificationsService, - protected translate: TranslateService + protected translate: TranslateService, + protected requestService: RequestService ) { } @@ -50,6 +52,7 @@ export class DeleteComColPageComponent implements if (response.isSuccessful) { const successMessage = this.translate.instant((dso as any).type + '.delete.notification.success'); this.notifications.success(successMessage) + this.dsoDataService.refreshCache(dso); } else { const errorMessage = this.translate.instant((dso as any).type + '.delete.notification.fail'); this.notifications.error(errorMessage) diff --git a/src/app/shared/comcol-page-handle/comcol-page-handle.component.spec.ts b/src/app/shared/comcol-page-handle/comcol-page-handle.component.spec.ts new file mode 100644 index 0000000000..e3e0c65920 --- /dev/null +++ b/src/app/shared/comcol-page-handle/comcol-page-handle.component.spec.ts @@ -0,0 +1,48 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { ComcolPageHandleComponent } from './comcol-page-handle.component'; + +const handle = 'http://localhost:4000/handle/123456789/2'; + +describe('ComcolPageHandleComponent', () => { + let component: ComcolPageHandleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ ComcolPageHandleComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ComcolPageHandleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should be empty if no content is passed', () => { + component.content = undefined; + fixture.detectChanges(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).toBeNull(); + }); + + it('should create a link pointing the handle when present', () => { + + component.content = handle; + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.getAttribute('href')).toBe(handle); + expect(link.nativeElement.innerHTML).toBe(handle); + + }); + +}); diff --git a/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts b/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts index bf403e9e88..2ce49ebea2 100644 --- a/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts +++ b/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts @@ -1,5 +1,4 @@ import { Component, Injectable, Input } from '@angular/core'; -import { UIURLCombiner } from '../../core/url-combiner/ui-url-combiner'; /** * This component builds a URL from the value of "handle" @@ -21,6 +20,6 @@ export class ComcolPageHandleComponent { @Input() content: string; public getHandle(): string { - return new UIURLCombiner('/handle/', this.content).toString(); + return this.content; } } diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.html b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.html new file mode 100644 index 0000000000..0cfa333ae0 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.html @@ -0,0 +1,6 @@ + + + diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss new file mode 100644 index 0000000000..c308aa71b9 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss @@ -0,0 +1,3 @@ +.btn-dark { + background-color: $admin-sidebar-bg; +} diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.spec.ts b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.spec.ts new file mode 100644 index 0000000000..b0d2f3a1fa --- /dev/null +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.spec.ts @@ -0,0 +1,76 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { DsoPageEditButtonComponent } from './dso-page-edit-button.component'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Item } from '../../../core/shared/item.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { By } from '@angular/platform-browser'; +import { TooltipModule } from 'ngx-bootstrap'; + +describe('DsoPageEditButtonComponent', () => { + let component: DsoPageEditButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + let dso: DSpaceObject; + + beforeEach(async(() => { + dso = Object.assign(new Item(), { + id: 'test-item', + _links: { + self: { href: 'test-item-selflink' } + } + }); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + TestBed.configureTestingModule({ + declarations: [ DsoPageEditButtonComponent ], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), TooltipModule.forRoot()], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageEditButtonComponent); + component = fixture.componentInstance; + component.dso = dso; + component.pageRoutePrefix = 'test'; + fixture.detectChanges(); + }); + + it('should check the authorization of the current user', () => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanEditMetadata, dso.self); + }); + + describe('when the user is authorized', () => { + beforeEach(() => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a link', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link).not.toBeNull(); + }); + }); + + describe('when the user is not authorized', () => { + beforeEach(() => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should not render a link', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts new file mode 100644 index 0000000000..230855a5a2 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts @@ -0,0 +1,43 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; + +@Component({ + selector: 'ds-dso-page-edit-button', + templateUrl: './dso-page-edit-button.component.html', + styleUrls: ['./dso-page-edit-button.component.scss'] +}) +/** + * Display a button linking to the edit page of a DSpaceObject + */ +export class DsoPageEditButtonComponent implements OnInit { + /** + * The DSpaceObject to display a button to the edit page for + */ + @Input() dso: DSpaceObject; + + /** + * The prefix of the route to the edit page (before the object's UUID, e.g. "items") + */ + @Input() pageRoutePrefix: string; + + /** + * A message for the tooltip on the button + * Supports i18n keys + */ + @Input() tooltipMsg: string; + + /** + * Whether or not the current user is authorized to edit the DSpaceObject + */ + isAuthorized$: Observable; + + constructor(protected authorizationService: AuthorizationDataService) { } + + ngOnInit() { + this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanEditMetadata, this.dso.self); + } + +} diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts new file mode 100644 index 0000000000..25b7465ada --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts @@ -0,0 +1,56 @@ +import { AuthorizedCollectionSelectorComponent } from './authorized-collection-selector.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../../../utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; +import { createPaginatedList } from '../../../testing/utils.test'; +import { Collection } from '../../../../core/shared/collection.model'; +import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; + +describe('AuthorizedCollectionSelectorComponent', () => { + let component: AuthorizedCollectionSelectorComponent; + let fixture: ComponentFixture; + + let collectionService; + let collection; + + beforeEach(async(() => { + collection = Object.assign(new Collection(), { + id: 'authorized-collection' + }); + collectionService = jasmine.createSpyObj('collectionService', { + getAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])) + }); + TestBed.configureTestingModule({ + declarations: [AuthorizedCollectionSelectorComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: SearchService, useValue: {} }, + { provide: CollectionDataService, useValue: collectionService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuthorizedCollectionSelectorComponent); + component = fixture.componentInstance; + component.types = [DSpaceObjectType.COLLECTION]; + fixture.detectChanges(); + }); + + describe('search', () => { + it('should call getAuthorizedCollection and return the authorized collection in a SearchResult', (done) => { + component.search('', 1).subscribe((result) => { + expect(collectionService.getAuthorizedCollection).toHaveBeenCalled(); + expect(result.page.length).toEqual(1); + expect(result.page[0].indexableObject).toEqual(collection); + done(); + }); + }); + }); +}); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts new file mode 100644 index 0000000000..0a19da54c8 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts @@ -0,0 +1,48 @@ +import { Component } from '@angular/core'; +import { DSOSelectorComponent } from '../dso-selector.component'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { map } from 'rxjs/operators'; +import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model'; +import { SearchResult } from '../../../search/search-result.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-authorized-collection-selector', + styleUrls: ['../dso-selector.component.scss'], + templateUrl: '../dso-selector.component.html' +}) +/** + * Component rendering a list of collections to select from + */ +export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent { + constructor(protected searchService: SearchService, + protected collectionDataService: CollectionDataService) { + super(searchService); + } + + /** + * Get a query to send for retrieving the current DSO + */ + getCurrentDSOQuery(): string { + return this.currentDSOId; + } + + /** + * Perform a search for authorized collections with the current query and page + * @param query Query to search objects for + * @param page Page to retrieve + */ + search(query: string, page: number): Observable>> { + return this.collectionDataService.getAuthorizedCollection(query, Object.assign({ + currentPage: page, + elementsPerPage: this.defaultPagination.pageSize + })).pipe( + getFirstSucceededRemoteDataPayload(), + map((list) => new PaginatedList(list.pageInfo, list.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col })))) + ); + } +} diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html index 92ddf4cf36..0e914a3783 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html @@ -7,15 +7,29 @@
+
- + +
diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.scss b/src/app/shared/dso-selector/dso-selector/dso-selector.component.scss new file mode 100644 index 0000000000..37d2ebeca7 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.scss @@ -0,0 +1,5 @@ +.scrollable-menu { + height: auto; + max-height: $dso-selector-list-max-height; + overflow-x: hidden; +} diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index 50b6090aef..7671f012ad 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -6,10 +6,10 @@ import { SearchService } from '../../../core/shared/search/search.service'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { ItemSearchResult } from '../../object-collection/shared/item-search-result.model'; import { Item } from '../../../core/shared/item.model'; -import { PaginatedList } from '../../../core/data/paginated-list'; -import { MetadataValue } from '../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; +import { hasValue } from '../../empty.util'; +import { createPaginatedList } from '../../testing/utils.test'; describe('DSOSelectorComponent', () => { let component: DSOSelectorComponent; @@ -18,19 +18,46 @@ describe('DSOSelectorComponent', () => { const currentDSOId = 'test-uuid-ford-sose'; const type = DSpaceObjectType.ITEM; - const searchResult = new ItemSearchResult(); - const item = new Item(); - item.metadata = { - 'dc.title': [Object.assign(new MetadataValue(), { - value: 'Item title', - language: undefined - })] - }; - searchResult.indexableObject = item; - searchResult.hitHighlights = {}; - const searchService = jasmine.createSpyObj('searchService', { - search: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [searchResult])) - }); + const searchResult = createSearchResult('current'); + + const firstPageResults = [ + createSearchResult('1'), + createSearchResult('2'), + createSearchResult('3'), + ]; + + const nextPageResults = [ + createSearchResult('4'), + createSearchResult('5'), + createSearchResult('6'), + ]; + + const searchService = { + search: (options: PaginatedSearchOptions) => { + if (hasValue(options.query) && options.query.startsWith('search.resourceid')) { + return createSuccessfulRemoteDataObject$(createPaginatedList([searchResult])); + } else if (options.pagination.currentPage === 1) { + return createSuccessfulRemoteDataObject$(createPaginatedList(firstPageResults)); + } else { + return createSuccessfulRemoteDataObject$(createPaginatedList(nextPageResults)); + } + } + } + + function createSearchResult(name: string): ItemSearchResult { + return Object.assign(new ItemSearchResult(), { + indexableObject: Object.assign(new Item(), { + id: `test-result-${name}`, + metadata: { + 'dc.title': [ + { + value: `test result - ${name}` + } + ] + } + }) + }) + } beforeEach(async(() => { TestBed.configureTestingModule({ @@ -58,13 +85,23 @@ describe('DSOSelectorComponent', () => { expect(component).toBeTruthy(); }); - it('should initially call the search method on the SearchService with the given DSO uuid', () => { - const searchOptions = new PaginatedSearchOptions({ - query: currentDSOId, - dsoTypes: [type], - pagination: (component as any).defaultPagination + describe('populating listEntries', () => { + it('should not be empty', () => { + expect(component.listEntries.length).toBeGreaterThan(0); }); - expect(searchService.search).toHaveBeenCalledWith(searchOptions); + it('should contain a combination of the current DSO and first page results', () => { + expect(component.listEntries).toEqual([searchResult, ...firstPageResults]); + }); + + describe('when current page increases', () => { + beforeEach(() => { + component.currentPage$.next(2); + }); + + it('should contain a combination of the current DSO, as well as first and second page results', () => { + expect(component.listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]); + }); + }); }); }); diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index 37c9a99f59..5bbcec4262 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -3,28 +3,33 @@ import { ElementRef, EventEmitter, Input, + OnDestroy, OnInit, Output, QueryList, ViewChildren } from '@angular/core'; import { FormControl } from '@angular/forms'; - -import { Observable } from 'rxjs'; -import { debounceTime, startWith, switchMap } from 'rxjs/operators'; +import { debounceTime, map, startWith, switchMap, tap } from 'rxjs/operators'; import { SearchService } from '../../../core/shared/search/search.service'; import { CollectionElementLinkType } from '../../object-collection/collection-element-link.type'; import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; -import { RemoteData } from '../../../core/data/remote-data'; -import { PaginatedList } from '../../../core/data/paginated-list'; -import { SearchResult } from '../../search/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ViewMode } from '../../../core/shared/view-mode.model'; +import { Context } from '../../../core/shared/context.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { hasValue, isEmpty, isNotEmpty } from '../../empty.util'; +import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { SearchResult } from '../../search/search-result.model'; @Component({ selector: 'ds-dso-selector', - // styleUrls: ['./dso-selector.component.scss'], + styleUrls: ['./dso-selector.component.scss'], templateUrl: './dso-selector.component.html' }) @@ -32,7 +37,7 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; * Component to render a list of DSO's of which one can be selected * The user can search the list by using the input field */ -export class DSOSelectorComponent implements OnInit { +export class DSOSelectorComponent implements OnInit, OnDestroy { /** * The view mode of the listed objects */ @@ -63,12 +68,29 @@ export class DSOSelectorComponent implements OnInit { /** * Default pagination for this feature */ - private defaultPagination = { id: 'dso-selector', currentPage: 1, pageSize: 5 } as any; + defaultPagination = { id: 'dso-selector', currentPage: 1, pageSize: 10 } as any; /** * List with search results of DSpace objects for the current query */ - listEntries$: Observable>>>; + listEntries: Array> = []; + + /** + * The current page to load + * Dynamically goes up as the user scrolls down until it reaches the last page possible + */ + currentPage$ = new BehaviorSubject(1); + + /** + * Whether or not the list contains a next page to load + * This allows us to avoid next pages from trying to load when there are none + */ + hasNextPage = false; + + /** + * Whether or not the list should be reset next time it receives a page to load + */ + resetList = false; /** * List of element references to all elements @@ -85,31 +107,107 @@ export class DSOSelectorComponent implements OnInit { */ linkTypes = CollectionElementLinkType; - constructor(private searchService: SearchService) { + /** + * Track whether the element has the mouse over it + */ + isMouseOver = false + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + public subs: Subscription[] = []; + + constructor(protected searchService: SearchService) { } /** - * Fills the listEntries$ variable with search results based on the input field's current value + * Fills the listEntries variable with search results based on the input field's current value and the current page * The search will always start with the initial currentDSOId value */ ngOnInit(): void { - this.input.setValue(this.currentDSOId); this.typesString = this.types.map((type: string) => type.toString().toLowerCase()).join(', '); - this.listEntries$ = this.input.valueChanges - .pipe( + + // Create an observable searching for the current DSO (return empty list if there's no current DSO) + let currentDSOResult$; + if (isNotEmpty(this.currentDSOId)) { + currentDSOResult$ = this.search(this.getCurrentDSOQuery(), 1); + } else { + currentDSOResult$ = observableOf(new PaginatedList(undefined, [])); + } + + // Combine current DSO, query and page + this.subs.push(observableCombineLatest( + currentDSOResult$, + this.input.valueChanges.pipe( debounceTime(this.debounceTime), - startWith(this.currentDSOId), - switchMap((query) => { - return this.searchService.search( - new PaginatedSearchOptions({ - query: query, - dsoTypes: this.types, - pagination: this.defaultPagination - }) - ) - } - ) - ) + startWith(''), + tap(() => this.currentPage$.next(1)) + ), + this.currentPage$ + ).pipe( + switchMap(([currentDSOResult, query, page]: [PaginatedList>, string, number]) => { + if (page === 1) { + // The first page is loading, this means we should reset the list instead of adding to it + this.resetList = true; + } + return this.search(query, page).pipe( + map((list) => { + // If it's the first page and no query is entered, add the current DSO to the start of the list + // If no query is entered, filter out the current DSO from the results, as it'll be displayed at the start of the list already + list.page = [ + ...((isEmpty(query) && page === 1) ? currentDSOResult.page : []), + ...list.page.filter((result) => isNotEmpty(query) || result.indexableObject.id !== this.currentDSOId) + ]; + return list; + }) + ); + }) + ).subscribe((list) => { + if (this.resetList) { + this.listEntries = list.page; + this.resetList = false; + } else { + this.listEntries.push(...list.page); + } + // Check if there are more pages available after the current one + this.hasNextPage = list.totalElements > this.listEntries.length; + })); + } + + /** + * Get a query to send for retrieving the current DSO + */ + getCurrentDSOQuery(): string { + return `search.resourceid:${this.currentDSOId}`; + } + + /** + * Perform a search for the current query and page + * @param query Query to search objects for + * @param page Page to retrieve + */ + search(query: string, page: number): Observable>> { + return this.searchService.search( + new PaginatedSearchOptions({ + query: query, + dsoTypes: this.types, + pagination: Object.assign({}, this.defaultPagination, { + currentPage: page + }) + }) + ).pipe( + getFirstSucceededRemoteDataPayload() + ); + } + + /** + * When the user reaches the bottom of the page (or almost) and there's a next page available, increase the current page + */ + onScrollDown() { + if (this.hasNextPage) { + this.currentPage$.next(this.currentPage$.value + 1); + } } /** @@ -120,4 +218,22 @@ export class DSOSelectorComponent implements OnInit { this.listElements.first.nativeElement.click(); } } + + /** + * Get the context for element with the given id + */ + getContext(id: string) { + if (id === this.currentDSOId) { + return Context.SideBarSearchModalCurrent; + } else { + return Context.SideBarSearchModal; + } + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } } diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts index 9e2710c9d3..64274f21e2 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -22,6 +22,7 @@ export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWra objectType = DSpaceObjectType.COLLECTION; selectorTypes = [DSpaceObjectType.COMMUNITY]; action = SelectorActionType.CREATE; + header = 'dso-selector.create.collection.sub-level'; constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html index ef8865ad87..8761e4eb9e 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -5,7 +5,7 @@
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index 5729ac8460..03d7732fb0 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -20,6 +20,7 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo objectType = DSpaceObjectType.ITEM; selectorTypes = [DSpaceObjectType.COLLECTION]; action = SelectorActionType.CREATE; + header = 'dso-selector.create.item.sub-level'; constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html index e1c18ec1e0..85d8797e66 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html @@ -5,6 +5,7 @@
diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index b56a901b12..59aeceea0f 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -23,6 +23,12 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ @Input() dsoRD: RemoteData; + /** + * Optional header to display above the selection list + * Supports i18n keys + */ + @Input() header: string; + /** * The type of the DSO that's being edited, created or exported */ diff --git a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts index 40aab2fad5..ef9abbb5ee 100644 --- a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts @@ -53,12 +53,26 @@ describe('ExportMetadataSelectorComponent', () => { const mockCollection: Collection = Object.assign(new Collection(), { id: 'test-collection-1-1', name: 'test-collection-1', - handle: 'fake/test-collection-1', + metadata: { + 'dc.identifier.uri': [ + { + language: null, + value: 'fake/test-collection-1' + } + ] + } }); const mockCommunity = Object.assign(new Community(), { id: 'test-uuid', - handle: 'fake/test-community-1', + metadata: { + 'dc.identifier.uri': [ + { + language: null, + value: 'fake/test-community-1' + } + ] + } }); const itemRD = createSuccessfulRemoteDataObject(mockItem); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts index 4a47ce5903..930dfe83d9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts @@ -54,7 +54,7 @@ describe('ExistingMetadataListElementComponent', () => { relationship = Object.assign(new Relationship(), { leftItem: leftItemRD$, rightItem: rightItemRD$ }); submissionId = '1234'; - reoRel = new ReorderableRelationship(relationship, true, relationshipService, {} as any, submissionId); + reoRel = new ReorderableRelationship(relationship, true, {} as any, {} as any, submissionId); } beforeEach(async(() => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts index d4ce3342e7..678402a1bc 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts @@ -138,7 +138,7 @@ export class ReorderableRelationship extends Reorderable { templateUrl: './existing-metadata-list-element.component.html', styleUrls: ['./existing-metadata-list-element.component.scss'] }) -export class ExistingMetadataListElementComponent implements OnInit, OnChanges, OnDestroy { +export class ExistingMetadataListElementComponent implements OnInit, OnChanges, OnDestroy { @Input() listId: string; @Input() submissionItem: Item; @Input() reoRel: ReorderableRelationship; diff --git a/src/app/shared/hover-class.directive.spec.ts b/src/app/shared/hover-class.directive.spec.ts new file mode 100644 index 0000000000..9c593f27c8 --- /dev/null +++ b/src/app/shared/hover-class.directive.spec.ts @@ -0,0 +1,35 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HoverClassDirective } from './hover-class.directive'; +import { By } from '@angular/platform-browser'; + +@Component({ + template: `
` +}) +class TestComponent { } + +describe('HoverClassDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let el: DebugElement; + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + declarations: [TestComponent, HoverClassDirective] + }).createComponent(TestComponent); + + fixture.detectChanges(); + component = fixture.componentInstance; + el = fixture.debugElement.query(By.css('div')); + }); + + it('should add the class on mouseenter and remove on mouseleave', () => { + el.triggerEventHandler('mouseenter', null); + fixture.detectChanges(); + expect(el.nativeElement.classList).toContain('ds-hover'); + + el.triggerEventHandler('mouseleave', null); + fixture.detectChanges(); + expect(el.nativeElement.classList).not.toContain('ds-hover'); + }); +}); diff --git a/src/app/shared/hover-class.directive.ts b/src/app/shared/hover-class.directive.ts new file mode 100644 index 0000000000..551e81a463 --- /dev/null +++ b/src/app/shared/hover-class.directive.ts @@ -0,0 +1,31 @@ +import { Directive, ElementRef, HostListener, Input } from '@angular/core'; + +@Directive({ + selector: '[dsHoverClass]' +}) +/** + * A directive adding a class to an element when hovered over + */ +export class HoverClassDirective { + /** + * The name of the class to add on hover + */ + @Input('dsHoverClass') hoverClass: string; +​ + constructor(public elementRef: ElementRef) { } +​ + /** + * On mouse enter, add the class to the element's class list + */ + @HostListener('mouseenter') onMouseEnter() { + this.elementRef.nativeElement.classList.add(this.hoverClass); + } +​ + /** + * On mouse leave, remove the class from the element's class list + */ + @HostListener('mouseleave') onMouseLeave() { + this.elementRef.nativeElement.classList.remove(this.hoverClass); + } +​ +} diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html index 91d8217ade..7a9481f2f1 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html @@ -1,15 +1,14 @@ -
- + + [ngModelOptions]="{standalone: true}" autocomplete="off"/>
- - + \ No newline at end of file diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts index cb36071c28..51664039f7 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts @@ -3,11 +3,9 @@ import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angula import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; -import { MetadataFieldDataService } from '../../../core/data/metadata-field-data.service'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { FilterInputSuggestionsComponent } from './filter-input-suggestions.component'; describe('FilterInputSuggestionsComponent', () => { @@ -23,13 +21,9 @@ describe('FilterInputSuggestionsComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule, ReactiveFormsModule], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule], declarations: [FilterInputSuggestionsComponent], - providers: [FormsModule, - ReactiveFormsModule, - { provide: MetadataFieldDataService, useValue: {} }, - { provide: ObjectUpdatesService, useValue: {} }, - ], + providers: [], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(FilterInputSuggestionsComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts index 49aa46b757..9e7d84d9ed 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts @@ -1,8 +1,5 @@ -import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { FormControl, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { MetadatumViewModel } from '../../../core/shared/metadata.models'; -import { MetadataFieldValidator } from '../../utils/metadatafield-validator.directive'; +import { Component, forwardRef, Input } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { InputSuggestionsComponent } from '../input-suggestions.component'; import { InputSuggestion } from '../input-suggestions.model'; @@ -24,39 +21,12 @@ import { InputSuggestion } from '../input-suggestions.model'; /** * Component representing a form with a autocomplete functionality */ -export class FilterInputSuggestionsComponent extends InputSuggestionsComponent implements OnInit { - - form: FormGroup; - - /** - * The current url of this page - */ - @Input() url: string; - - /** - * The metadatum of this field - */ - @Input() metadata: MetadatumViewModel; - +export class FilterInputSuggestionsComponent extends InputSuggestionsComponent { /** * The suggestions that should be shown */ @Input() suggestions: InputSuggestion[] = []; - constructor(private metadataFieldValidator: MetadataFieldValidator, - private objectUpdatesService: ObjectUpdatesService) { - super(); - } - - ngOnInit() { - this.form = new FormGroup({ - metadataNameField: new FormControl(this._value, { - asyncValidators: [this.metadataFieldValidator.validate.bind(this.metadataFieldValidator)], - validators: [Validators.required] - }) - }); - } - onSubmit(data) { this.value = data; this.submitSuggestion.emit(data); @@ -70,15 +40,4 @@ export class FilterInputSuggestionsComponent extends InputSuggestionsComponent i this.queryInput.nativeElement.focus(); return false; } - - /** - * Check if the input is valid according to validator and send (in)valid state to store - * @param form Form with input - */ - checkIfValidInput(form) { - this.valid = !(form.get('metadataNameField').status === 'INVALID' && (form.get('metadataNameField').dirty || form.get('metadataNameField').touched)); - this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, this.valid); - return this.valid; - } - } diff --git a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html new file mode 100644 index 0000000000..91d8217ade --- /dev/null +++ b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html @@ -0,0 +1,24 @@ +
+ + + +
+ diff --git a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.spec.ts b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.spec.ts new file mode 100644 index 0000000000..82e838effc --- /dev/null +++ b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.spec.ts @@ -0,0 +1,63 @@ +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { MetadataFieldDataService } from '../../../core/data/metadata-field-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ValidationSuggestionsComponent } from './validation-suggestions.component'; + +describe('ValidationSuggestionsComponent', () => { + + let comp: ValidationSuggestionsComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let el: HTMLElement; + const suggestions = [{ displayValue: 'suggestion uno', value: 'suggestion uno' }, { + displayValue: 'suggestion dos', + value: 'suggestion dos' + }, { displayValue: 'suggestion tres', value: 'suggestion tres' }]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule, ReactiveFormsModule], + declarations: [ValidationSuggestionsComponent], + providers: [FormsModule, + ReactiveFormsModule, + { provide: MetadataFieldDataService, useValue: {} }, + { provide: ObjectUpdatesService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ValidationSuggestionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ValidationSuggestionsComponent); + + comp = fixture.componentInstance; // LoadingComponent test instance + comp.suggestions = suggestions; + // query for the message